|
|
@@ -0,0 +1,558 @@
|
|
|
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output, 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.emit()">
|
|
|
+ <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({ required: true }) sessionToken = '';
|
|
|
+ @Input({ required: true }) userId = '';
|
|
|
+ @Input() displayName = '';
|
|
|
+ @Output() logout = new EventEmitter<void>();
|
|
|
+
|
|
|
+ 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);
|
|
|
+
|
|
|
+ ngOnInit(): void {
|
|
|
+ void this.initialize();
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ this.stopPolling();
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 async initialize(): Promise<void> {
|
|
|
+ this.statusLoading.set(true);
|
|
|
+ try {
|
|
|
+ const cachedPhone = localStorage.getItem('apig_phone')?.trim() || '';
|
|
|
+ if (cachedPhone) this.phone.set(cachedPhone);
|
|
|
+ const me = await this.fetchMe();
|
|
|
+ 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 (data) this.setResult(data);
|
|
|
+ } catch (error: any) {
|
|
|
+ console.warn('[EnterpriseTrial] status init failed:', error?.message);
|
|
|
+ } finally {
|
|
|
+ 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
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|