| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586 |
- import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal } from '@angular/core';
- import { CommonModule } from '@angular/common';
- import { buildEnterpriseAgentPrompt, maskEnterpriseAgentPrompt } from './enterprise-agent-prompt';
- const APP_ID = 'ncloudmaster';
- const API_BASE = 'https://server.fmode.cn';
- const SUBSCRIPTION_BASE = API_BASE + '/api/fmode/enterprise/subscription';
- interface BackendConfig {
- appId?: string;
- masterKey?: string;
- serverURL?: string;
- functionsURL?: string;
- status?: string;
- }
- interface SubscriptionView {
- active: boolean;
- status: string;
- planCode: string;
- planName: string;
- expireAt: string | null;
- remainingDays: number;
- provisionStatus: string;
- provisionError?: string;
- backend: BackendConfig | null;
- }
- interface TrialView {
- available: boolean;
- active: boolean;
- claimedAt: string | null;
- expireAt: string | null;
- remainingDays: number;
- planName: string;
- durationDays: number;
- bannerTitle: string;
- bannerText: string;
- }
- interface TrialResult {
- company?: { objectId: string; name: string };
- subscription?: SubscriptionView;
- trial?: TrialView;
- backend?: BackendConfig;
- }
- interface AgentPromptResult {
- prompt: string;
- maskedPrompt: string;
- rulesUrl?: string;
- expireAt?: string | null;
- }
- @Component({
- selector: 'app-enterprise-trial',
- standalone: true,
- imports: [CommonModule],
- changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
- <div class="ent-page">
- <header class="page-header">
- <a class="brand" href="https://fmode.cn/" aria-label="Fmode 未来飞马">
- <span class="brand-mark">F</span>
- <span>Fmode<span> · 企业微服务</span></span>
- </a>
- <div class="user-area">
- <span>{{ displayName || userId }}</span>
- <button type="button" class="icon-button" title="退出登录" aria-label="退出登录" (click)="logout()">
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 17l5-5-5-5M15 12H3M21 3v18h-7"/></svg>
- </button>
- </div>
- </header>
- <main class="purchase-layout">
- <section class="configuration" aria-labelledby="trial-title">
- <div class="product-name">企业微服务 · 云上跑</div>
- <h1 id="trial-title">领取 7 天企业微服务试用</h1>
- <p class="lead">填写姓名与手机号即可自动开通。领取后立即获得独立的 Parse 数据库、专属服务端与云函数地址,7 天免费体验,结束可无缝续费。</p>
- @if (statusLoading()) {
- <div class="loading-state"><span class="spinner"></span>正在读取试用状态...</div>
- } @else if (alreadyActive() || claimed()) {
- <section class="active-subscription">
- <div>
- <span class="status-dot"></span>
- <strong>{{ alreadyActive() ? '企业微服务已开通' : '试用已领取' }}</strong>
- </div>
- <dl>
- <div><dt>有效期至</dt><dd>{{ formatDate(expireAt()) }}</dd></div>
- <div><dt>剩余天数</dt><dd>{{ remainingDays() }} 天</dd></div>
- </dl>
- </section>
- } @else {
- <section class="trial-panel" aria-labelledby="trial-form-title">
- <div class="trial-copy">
- <span class="trial-kicker">新人专享</span>
- <h2 id="trial-form-title">填写信息,立即开通</h2>
- <p>7 天内可体验完整云上跑专属微服务能力,每位用户限领一次。</p>
- </div>
- <div class="trial-form">
- <label class="trial-field">
- <span>姓名</span>
- <input type="text" placeholder="请输入姓名" maxlength="120" autocomplete="name" [value]="name()" (input)="name.set($any($event.target).value)" />
- </label>
- <label class="trial-field">
- <span>手机号</span>
- <input type="tel" inputmode="numeric" placeholder="请输入手机号" maxlength="11" autocomplete="tel" [value]="phone()" (input)="phone.set($any($event.target).value)" />
- </label>
- <button type="button" class="primary-button" [disabled]="submitting() || !validForm" (click)="submitTrial()">
- @if (submitting()) { <span class="spinner light"></span> }
- {{ submitting() ? '正在开通,请稍候...' : '免费领取 7 天试用' }}
- </button>
- </div>
- @if (errorMessage()) { <div class="trial-message trial-message-error">{{ errorMessage() }}</div> }
- </section>
- }
- </section>
- <aside class="order-summary" aria-live="polite">
- <div class="summary-heading">
- <span>试用权益</span>
- <b>7 天免费</b>
- </div>
- @if (alreadyActive()) {
- <div class="result-block">
- <div class="result-icon"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
- <div class="price-main">已开通</div>
- <p class="price-caption">有效期至 {{ formatDate(expireAt()) }}</p>
- @if (backend()) {
- <dl class="config-lines">
- <div><dt>Server URL</dt><dd class="mono">{{ backend()!.serverURL }}</dd></div>
- <div><dt>Functions URL</dt><dd class="mono">{{ backend()!.functionsURL }}</dd></div>
- <div><dt>App ID</dt><dd class="mono">{{ backend()!.appId }}</dd></div>
- <div><dt>Master Key</dt><dd class="mono">{{ maskRuntimeSecret(backend()!.masterKey) }}</dd></div>
- </dl>
- } @else if (provisionFailed()) {
- <div class="flow-error">资源开通失败{{ provisionError() ? ':' + provisionError() : '' }}。</div>
- <button type="button" class="primary-button" [disabled]="retryBusy()" (click)="retryProvision()">
- @if (retryBusy()) { <span class="spinner light"></span> }
- {{ retryBusy() ? '正在重试开通...' : '重试开通' }}
- </button>
- } @else {
- <div class="pending-notice">资源开通处理中,可稍后刷新查看连接配置。</div>
- }
- @if (agentPrompt()) {
- <div class="agent-prompt-block">
- <div class="agent-prompt-heading">
- <strong>交给 Agent 的闭环提示词</strong>
- <span>已脱敏预览</span>
- </div>
- <div class="agent-prompt-preview"><pre>{{ maskedAgentPrompt() }}</pre></div>
- <button type="button" class="copy-button" (click)="copyAgentPrompt()">
- @if (promptCopied()) { 已复制,可发送给 Agent } @else { 复制提示词给 Agent }
- </button>
- </div>
- } @else {
- <button type="button" class="secondary-button full" [disabled]="promptBusy()" (click)="requestAgentPrompt()">
- @if (promptBusy()) { <span class="spinner light"></span> }
- {{ promptBusy() ? '正在生成提示词...' : '生成 Agent 闭环提示词' }}
- </button>
- }
- <button type="button" class="console-button" (click)="openMicroserviceConsole()">
- 进入微服控制台
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
- </button>
- <button type="button" class="secondary-button full" [disabled]="refreshBusy() || retryBusy()" (click)="refreshStatus()">
- @if (refreshBusy()) { <span class="spinner light"></span> }
- 刷新状态
- </button>
- </div>
- } @else if (claimed()) {
- <div class="result-block">
- <div class="result-icon"><svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></div>
- <div class="price-main">试用已领取</div>
- <p class="price-caption">该企业已领取过 7 天试用,请在 Fmode Studio 企业端控制台查看开通状态。</p>
- <button type="button" class="secondary-button full" [disabled]="refreshBusy()" (click)="refreshStatus()">
- @if (refreshBusy()) { <span class="spinner light"></span> }
- 刷新状态
- </button>
- </div>
- } @else {
- <div class="benefit-list">
- <div class="benefit-item">
- <span class="benefit-icon"><svg viewBox="0 0 24 24"><path d="M4 7v10M8 7v10M12 7v10M16 7v10M20 7v10"/></svg></span>
- <div><strong>独立 Parse 数据库</strong><small>专属数据表与多租户隔离</small></div>
- </div>
- <div class="benefit-item">
- <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>
- <div><strong>专属服务端与云函数</strong><small>Server / Functions 地址开箱即用</small></div>
- </div>
- <div class="benefit-item">
- <span class="benefit-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
- <div><strong>一键进入云上跑</strong><small>配置直达,无缝衔接项目开发</small></div>
- </div>
- </div>
- <p class="billing-note">领取即代表同意开通企业微服务。试用结束后可选择包月 / 包季 / 包年续费。</p>
- }
- </aside>
- </main>
- </div>
- `,
- styles: [`
- :host{display:block;min-height:100vh;color:#edf2f3;background:#0b0e10;font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif}
- *{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}
- button:focus-visible,input:focus-visible{outline:2px solid #25b983;outline-offset:3px}
- .ent-page{min-height:100vh;background:linear-gradient(180deg,#0b0e10 0,#101619 68%,#0b0e10 100%)}
- .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)}
- .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}
- .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}
- .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}
- .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}
- .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}
- .loading-state{min-height:220px;display:flex;align-items:center;justify-content:center;gap:9px;color:#829094;font-size:13px}
- .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}
- .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}
- .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}
- .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}
- .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}
- .billing-note{margin:16px 0 0;color:#6f7b7f;font-size:10px;line-height:1.6;text-align:center}
- .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}
- .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}
- .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}
- .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}
- .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)}}
- @media(max-width:880px){.purchase-layout{grid-template-columns:1fr;gap:38px;align-items:start}.configuration{max-width:none}.order-summary{min-height:0}}
- @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}}
- @media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}
- `]
- })
- export class EnterpriseTrialComponent implements OnInit, OnDestroy {
- @Input() sessionToken = '';
- @Input() userId = '';
- @Input() displayName = '';
- readonly name = signal('');
- readonly phone = signal('');
- readonly errorMessage = signal('');
- readonly submitting = signal(false);
- readonly statusLoading = signal(true);
- readonly refreshBusy = signal(false);
- readonly retryBusy = signal(false);
- readonly result = signal<TrialResult | null>(null);
- readonly agentPrompt = signal<AgentPromptResult | null>(null);
- readonly maskedAgentPrompt = signal('');
- readonly promptBusy = signal(false);
- readonly promptCopied = signal(false);
- private runtimeUser: any | null = null;
- private readonly pollTimer = signal<ReturnType<typeof setInterval> | null>(null);
- private readonly authHandler = (event: Event) => {
- const detail = (event as CustomEvent<{ userId: string; sessionToken: string; displayName: string }>).detail;
- if (!detail?.sessionToken) return;
- this.userId = detail.userId;
- this.sessionToken = detail.sessionToken;
- this.displayName = detail.displayName || detail.userId;
- this.needRouteAuthRefresh();
- };
- ngOnInit(): void {
- this.sessionToken ||= localStorage.getItem('apig_session_token') || '';
- this.userId ||= localStorage.getItem('apig_user_id') || '';
- this.displayName ||= localStorage.getItem('apig_display_name') || '';
- window.addEventListener('fmode-auth-success', this.authHandler);
- void this.initialize();
- }
- ngOnDestroy(): void {
- window.removeEventListener('fmode-auth-success', this.authHandler);
- this.stopPolling();
- }
- logout(): void {
- // 组件由 Router 独立渲染,App 侧通过 fmode-logout 事件接收并清理登录态
- window.dispatchEvent(new CustomEvent('fmode-logout'));
- }
- private needRouteAuthRefresh(): void {
- this.errorMessage.set('');
- void this.initialize();
- }
- get validForm(): boolean {
- return this.name().trim().length > 0 && /^1[3-9]\d{9}$/.test(this.phone().trim());
- }
- alreadyActive(): boolean {
- return Boolean(this.result()?.trial?.active || this.result()?.subscription?.active);
- }
- claimed(): boolean {
- return Boolean(this.result()?.trial?.claimedAt) && !this.alreadyActive();
- }
- expireAt(): string | null {
- return this.result()?.trial?.expireAt || this.result()?.subscription?.expireAt || null;
- }
- remainingDays(): number {
- return this.result()?.trial?.remainingDays ?? this.result()?.subscription?.remainingDays ?? 0;
- }
- backend(): BackendConfig | null {
- return this.result()?.backend || this.result()?.subscription?.backend || null;
- }
- provisionFailed(): boolean {
- return this.result()?.subscription?.provisionStatus === 'failed';
- }
- provisionError(): string {
- return this.result()?.subscription?.provisionError || '';
- }
- formatDate(value?: string | null): string {
- if (!value) return '待开通';
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? '待确认' : date.toLocaleDateString('zh-CN');
- }
- maskRuntimeSecret(value?: string): string {
- const text = String(value || '').trim();
- if (!text) return '未提供';
- if (text.length <= 10) return `${text.slice(0, 2)}***`;
- return `${text.slice(0, 6)}***${text.slice(-4)}`;
- }
- async refreshStatus(): Promise<void> {
- this.refreshBusy.set(true);
- this.errorMessage.set('');
- try {
- const data = await this.loadStatus();
- if (data) this.setResult(data);
- } catch (error: any) {
- this.errorMessage.set(error?.message || '状态刷新失败,请稍后重试。');
- } finally {
- this.refreshBusy.set(false);
- }
- }
- async retryProvision(): Promise<void> {
- if (this.retryBusy()) return;
- this.retryBusy.set(true);
- this.errorMessage.set('');
- try {
- const result = await this.authJson('/api/fmode/enterprise/subscription/provision/retry', 'POST', {});
- if (result.code !== 200) throw new Error(result.mess || '重试开通失败');
- if (result.data) this.setResult(result.data);
- this.startPolling();
- } catch (error: any) {
- this.errorMessage.set(error?.message || '重试开通失败,请稍后重试。');
- } finally {
- this.retryBusy.set(false);
- }
- }
- async submitTrial(): Promise<void> {
- if (this.submitting() || !this.validForm) return;
- this.submitting.set(true);
- this.errorMessage.set('');
- try {
- const result = await this.authJson('/api/fmode/enterprise/subscription/trial-standalone', 'POST', {
- name: this.name().trim(),
- mobile: this.phone().trim()
- });
- if (result.code !== 200 || !result.data) throw new Error(result.mess || '开通失败,请稍后重试。');
- this.setResult(result.data);
- this.startPolling();
- } catch (error: any) {
- this.errorMessage.set(error?.message || '开通失败,请稍后重试。');
- } finally {
- this.submitting.set(false);
- }
- }
- private initializeSeq = 0;
- private async initialize(): Promise<void> {
- const seq = ++this.initializeSeq;
- this.statusLoading.set(true);
- try {
- const cachedPhone = localStorage.getItem('apig_phone')?.trim() || '';
- if (cachedPhone) this.phone.set(cachedPhone);
- const me = await this.fetchMe();
- if (seq !== this.initializeSeq) return;
- this.runtimeUser = me;
- const parsePhone = this.userPhone(me);
- if (!cachedPhone && parsePhone) {
- this.phone.set(parsePhone);
- localStorage.setItem('apig_phone', parsePhone);
- }
- const data = await this.loadStatus();
- if (seq !== this.initializeSeq) return;
- if (data) this.setResult(data);
- } catch (error: any) {
- console.warn('[EnterpriseTrial] status init failed:', error?.message);
- } finally {
- // 仅最新一次 initialize 负责收尾,避免并发初始化时旧请求覆盖新状态
- if (seq === this.initializeSeq) this.statusLoading.set(false);
- }
- }
- private async loadStatus(): Promise<TrialResult | null> {
- try {
- const result = await this.authJson('/api/fmode/enterprise/subscription/status', 'POST', {});
- if (result.code === 200 && result.data) return result.data as TrialResult;
- return null;
- } catch (error: any) {
- if (Number(error?.status) === 400 || Number(error?.status) === 403) return null;
- throw error;
- }
- }
- private async fetchMe(): Promise<any | null> {
- try {
- const response = await fetch(`${API_BASE}/parse/users/me?include=company`, {
- headers: this.parseHeaders()
- });
- if (!response.ok) return null;
- return await response.json();
- } catch {
- return null;
- }
- }
- private userPhone(user: any): string {
- const value = String(user?.mobile || user?.mobilePhoneNumber || '').trim();
- return /^1[3-9]\d{9}$/.test(value) ? value : '';
- }
- private startPolling(): void {
- this.stopPolling();
- let attempts = 0;
- const maxAttempts = 45; // 4s/次,最长约 3 分钟,兜底防止无限轮询
- const timer = setInterval(() => {
- attempts += 1;
- void this.loadStatus().then(data => {
- if (!data) return;
- this.setResult(data);
- const status = data.subscription?.provisionStatus;
- const hasBackend = Boolean(data.backend || data.subscription?.backend);
- // 开通成功(拿到 backend)、开通失败、或达到轮询上限时终止
- if (status === 'failed' || hasBackend || attempts >= maxAttempts) {
- this.stopPolling();
- }
- }).catch(() => {
- // 状态查询异常也兜底终止,避免无限轮询
- if (attempts >= maxAttempts) this.stopPolling();
- });
- }, 4000);
- this.pollTimer.set(timer);
- }
- private stopPolling(): void {
- const timer = this.pollTimer();
- if (timer) {
- clearInterval(timer);
- this.pollTimer.set(null);
- }
- }
- async requestAgentPrompt(): Promise<void> {
- await this.loadAgentPrompt();
- }
- async copyAgentPrompt(): Promise<void> {
- const prompt = this.agentPrompt()?.prompt;
- if (!prompt) return;
- try {
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(prompt);
- } else {
- const textarea = document.createElement('textarea');
- textarea.value = prompt;
- textarea.setAttribute('readonly', '');
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- document.body.appendChild(textarea);
- textarea.select();
- if (!document.execCommand('copy')) throw new Error('copy failed');
- textarea.remove();
- }
- this.promptCopied.set(true);
- window.setTimeout(() => this.promptCopied.set(false), 2200);
- } catch {
- this.errorMessage.set('浏览器未允许复制,请在 HTTPS 或 localhost 页面中重试。');
- }
- }
- openMicroserviceConsole(): void {
- const backend = this.backend();
- if (!backend?.serverURL || !backend.appId || !backend.masterKey || !this.sessionToken) {
- this.errorMessage.set('微服务配置或登录凭据尚未就绪,请刷新状态后重试。');
- return;
- }
- const url = new URL('https://app.fmode.cn/dev/studio/dashboard');
- url.searchParams.set('enterprise', '1');
- url.searchParams.set('serverURL', backend.serverURL);
- url.searchParams.set('appId', backend.appId);
- url.searchParams.set('masterKey', backend.masterKey);
- url.searchParams.set('name', this.result()?.company?.name || `企业服务 · ${backend.appId}`);
- if (backend.functionsURL) url.searchParams.set('functionsURL', backend.functionsURL);
- url.searchParams.set('session', this.sessionToken);
- window.location.assign(url.toString());
- }
- private setResult(data: TrialResult): void {
- this.result.set(data);
- const backend = data.backend || data.subscription?.backend;
- if (backend && data.subscription?.provisionStatus === 'success' && !this.agentPrompt() && !this.promptBusy()) {
- void this.loadAgentPrompt();
- }
- }
- private async loadAgentPrompt(): Promise<void> {
- if (this.promptBusy() || this.agentPrompt()) return;
- this.promptBusy.set(true);
- this.errorMessage.set('');
- try {
- const backend = this.backend();
- if (!backend) throw new Error('企业 Parse 后端尚未就绪');
- const newApiToken = String(
- localStorage.getItem('fmode_newapi_token')
- || localStorage.getItem('newapiToken')
- || this.runtimeUser?.fmodeApiToken
- || this.runtimeUser?.newapiToken
- || ''
- ).trim();
- if (!newApiToken) throw new Error('未找到 Fmode New API token,请先完成账户同步');
- if (!this.sessionToken) throw new Error('未找到当前登录 sessionToken,请重新登录后重试');
- const prompt = buildEnterpriseAgentPrompt({
- backend,
- newApiToken,
- sessionToken: this.sessionToken,
- companyId: this.result()?.company?.objectId,
- companyName: this.result()?.company?.name,
- expireAt: this.expireAt()
- });
- this.agentPrompt.set({ prompt, maskedPrompt: maskEnterpriseAgentPrompt(prompt) });
- this.maskedAgentPrompt.set(maskEnterpriseAgentPrompt(prompt));
- } catch (error: any) {
- this.errorMessage.set(error?.message || '提示词生成失败,请稍后重试。');
- } finally {
- this.promptBusy.set(false);
- }
- }
- private async authJson(path: string, method: 'GET' | 'POST', body?: unknown): Promise<any> {
- // 后端 /enterprise/* 通过 body.sessionToken 鉴权
- // (enterprise-service.sessionUserFromRequest 优先读 req.body.sessionToken),
- // 因此无需在 header 重复携带 Authorization / X-Parse-Session-Token。
- const headers: Record<string, string> = {
- 'Content-Type': 'application/json'
- };
- const response = await fetch(`${API_BASE}${path}`, {
- method,
- headers,
- cache: 'no-store',
- // 明确不带 cookie/凭据:接口使用 body 的 sessionToken 鉴权;
- // 若携带 credentials: "include",服务端返回 Access-Control-Allow-Origin: * 会被浏览器拦截(CORS 跨域报错)。
- credentials: 'omit',
- body: method === 'GET' ? undefined : JSON.stringify({ ...(body || {}), sessionToken: this.sessionToken })
- });
- const result = await response.json().catch(() => ({}));
- if (!response.ok || (result.code && result.code !== 200)) {
- const error = new Error(result.mess || result.message || '请求失败') as Error & { status?: number };
- error.status = response.status || result.code;
- throw error;
- }
- return result;
- }
- private parseHeaders(): Record<string, string> {
- return {
- 'Content-Type': 'application/json',
- 'X-Parse-Application-Id': APP_ID,
- 'X-Parse-Session-Token': this.sessionToken
- };
- }
- }
|