浏览代码

feat:新增企业领取7天微服务

cb 1 月之前
父节点
当前提交
d47abdff35
共有 5 个文件被更改,包括 641 次插入20 次删除
  1. 14 5
      src/app/app.html
  2. 20 9
      src/app/app.ts
  3. 68 0
      src/app/enterprise-agent-prompt.ts
  4. 529 0
      src/app/enterprise-trial.component.ts
  5. 10 6
      src/app/login-modal.component.ts

+ 14 - 5
src/app/app.html

@@ -1,8 +1,9 @@
 <!-- Login Modal -->
 <app-login-modal
   [visible]="needLogin && !isLauncherPage"
-  [skillInstall]="isSkillInstallPage || isQiweiSkillPage"
-  [qiweiSkill]="isQiweiSkillPage"
+  [skillInstall]="isSkillInstallPage || isQiweiSkillPage || isEnterpriseTrialPage"
+  [qiweiSkill]="isQiweiSkillPage || isEnterpriseTrialPage"
+  [qiweiTrial]="isEnterpriseTrialPage"
   (loginSuccess)="onLoginSuccess($event)">
 </app-login-modal>
 
@@ -22,8 +23,16 @@
   (logout)="logout()">
 </app-qiwei-skill-install>
 
+<app-enterprise-trial
+  *ngIf="isEnterpriseTrialPage && !needLogin && sessionToken && userId"
+  [sessionToken]="sessionToken"
+  [userId]="userId"
+  [displayName]="loggedInUser"
+  (logout)="logout()">
+</app-enterprise-trial>
+
 <!-- Header -->
-<div class="page-header" *ngIf="!isSkillInstallPage && !isQiweiSkillPage">
+<div class="page-header" *ngIf="!isSkillInstallPage && !isQiweiSkillPage && !isEnterpriseTrialPage">
   <div class="logo" *ngIf="!isLauncherPage">BRAIN<span>HACK</span></div>
   <div class="logo" *ngIf="isLauncherPage">FMODE<span>STUDIO</span></div>
   <div class="subtitle">{{pageSubtitle}}</div>
@@ -126,7 +135,7 @@
 </div>
 
 <!-- Main -->
-<div class="main-container" *ngIf="!isLauncherPage && !isSkillInstallPage && !isQiweiSkillPage">
+<div class="main-container" *ngIf="!isLauncherPage && !isSkillInstallPage && !isQiweiSkillPage && !isEnterpriseTrialPage">
 
   <!-- Workshop Entry -->
   <div class="workshop-entry neu-raised" *ngIf="!isWorkshopMode && !showSuccess && !needLogin">
@@ -517,7 +526,7 @@
 </div>
 
 <!-- QR Modal -->
-<div class="modal-overlay" [class.show]="showQrModal" *ngIf="!isSkillInstallPage && !isQiweiSkillPage">
+<div class="modal-overlay" [class.show]="showQrModal" *ngIf="!isSkillInstallPage && !isQiweiSkillPage && !isEnterpriseTrialPage">
   <div class="modal-box neu-raised">
     <button class="modal-close" (click)="cancelPayment()">&times;</button>
     <div class="modal-title">微信扫码支付</div>

+ 20 - 9
src/app/app.ts

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
 import { LoginModalComponent } from './login-modal.component';
 import { SkillInstallComponent } from './skill-install.component';
 import { QiweiSkillInstallComponent } from './qiwei-skill-install.component';
+import { EnterpriseTrialComponent } from './enterprise-trial.component';
 
 import QRCode from 'qrcode';
 
@@ -286,7 +287,7 @@ const LAUNCHER_URL_LIST = 'https://repos.fmode.cn/x/launcher/url.txt';
 
 @Component({
   selector: 'app-root',
-  imports: [CommonModule, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent],
+  imports: [CommonModule, LoginModalComponent, SkillInstallComponent, QiweiSkillInstallComponent, EnterpriseTrialComponent],
   templateUrl: './app.html',
   styleUrl: './app.scss'
 })
@@ -307,6 +308,7 @@ export class App implements OnInit {
   isLauncherPage = false;
   isSkillInstallPage = false;
   isQiweiSkillPage = false;
+  isEnterpriseTrialPage = false;
 
   // Login state
   needLogin = false;
@@ -456,10 +458,14 @@ export class App implements OnInit {
       window.location.pathname.includes('/qiwei-trial') ||
       window.location.hash.startsWith('#/qiwei-skill') ||
       window.location.hash.startsWith('#/qiwei-trial');
-    if (this.isSkillInstallPage) {
-      document.title = '开启 VOC 数据洞察 | Fmode';
+    this.isEnterpriseTrialPage = window.location.pathname.includes('/enterprise-trial') ||
+      window.location.hash.startsWith('#/enterprise-trial');
+    if (this.isEnterpriseTrialPage) {
+      document.title = '领取 7 天企业微服务试用 | Fmode';
     } else if (this.isQiweiSkillPage) {
       document.title = '领取 7 天企微数字员工试用 | Fmode';
+    } else if (this.isSkillInstallPage) {
+      document.title = '开启 VOC 数据洞察 | Fmode';
     }
     if (this.isLauncherPage) {
       this.loadLauncherDownloads();
@@ -486,7 +492,7 @@ export class App implements OnInit {
     // 1. URL 已提供足够参数 → 直接加载
     if (this.authId || (this.userId && this.apigId)) {
       console.log('[AUTH] URL 参数充足,直接加载');
-      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
       return;
     }
 
@@ -596,8 +602,10 @@ export class App implements OnInit {
       // become 成功 — 写入状态
       this.userId = data.objectId;
       this.sessionToken = token;
-      this.loggedInUser = data.nickname || data.username || data.mobilePhoneNumber || data.objectId;
+      this.loggedInUser = data.nickname || data.username || data.mobile || data.mobilePhoneNumber || data.objectId;
       this.companyId = data.company?.objectId || '';
+      const phone = String(data.mobile || data.mobilePhoneNumber || '').trim();
+      if (phone) localStorage.setItem('apig_phone', phone);
       console.log('[AUTH] become 成功, user:', data.objectId, data.username || '', 'company:', this.companyId || '(无)');
 
       // 持久化到 localStorage
@@ -606,7 +614,7 @@ export class App implements OnInit {
       localStorage.setItem('apig_display_name', this.loggedInUser);
 
       this.cdr.detectChanges();
-      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
     } catch (e: any) {
       console.error('[AUTH] become 请求异常:', e);
       this.errorMsg = 'Token 登录失败: ' + e.message;
@@ -634,7 +642,7 @@ export class App implements OnInit {
         return;
       }
       // token 有效 — 更新 displayName(可能 localStorage 里是旧的)
-      const serverName = data.nickname || data.username || data.mobilePhoneNumber || userId;
+      const serverName = data.nickname || data.username || data.mobile || data.mobilePhoneNumber || userId;
       if (serverName && serverName !== this.loggedInUser) {
         this.loggedInUser = serverName;
         localStorage.setItem('apig_display_name', serverName);
@@ -647,13 +655,15 @@ export class App implements OnInit {
       }
       // 提取 companyId(可选:后端扣费只要 user 指针即可,Company 存在时才附带)
       this.companyId = data.company?.objectId || '';
+      const phone = String(data.mobile || data.mobilePhoneNumber || '').trim();
+      if (phone) localStorage.setItem('apig_phone', phone);
       console.log('[AUTH] token 验证通过, user:', data.objectId, data.username || '', 'company:', this.companyId || '(用户无 Company,仅用 user 指针扣费)');
       this.cdr.detectChanges();
-      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
     } catch (e) {
       console.warn('[AUTH] 验证 token 失败:', e);
       // 网络错误时仍然尝试加载(可能离线缓存能用)
-      if (!this.isSkillInstallPage && !this.isQiweiSkillPage) this.loadApig();
+      if (!this.isSkillInstallPage && !this.isQiweiSkillPage && !this.isEnterpriseTrialPage) this.loadApig();
     }
   }
 
@@ -674,6 +684,7 @@ export class App implements OnInit {
     localStorage.removeItem('apig_user_id');
     localStorage.removeItem('apig_session_token');
     localStorage.removeItem('apig_display_name');
+    localStorage.removeItem('apig_phone');
     this.userId = '';
     this.loggedInUser = '';
     this.sessionToken = '';

+ 68 - 0
src/app/enterprise-agent-prompt.ts

@@ -0,0 +1,68 @@
+export const ENTERPRISE_RULES_URL = 'https://repos.fmode.cn/x/fmode-studio/fmode-enterprise-agent-loop.md';
+export const ENTERPRISE_RULES_MANIFEST_URL = 'https://repos.fmode.cn/x/fmode-rules/claude/manifest.json';
+
+export interface EnterprisePromptConfig {
+  appId?: string;
+  masterKey?: string;
+  serverURL?: string;
+  functionsURL?: string;
+}
+
+export interface EnterprisePromptInput {
+  backend: EnterprisePromptConfig;
+  newApiToken: string;
+  companyId?: string;
+  companyName?: string;
+  expireAt?: string | null;
+}
+
+const value = (input: unknown, fallback = '(未提供)'): string => {
+  const text = String(input ?? '').trim();
+  return text || fallback;
+};
+
+export function buildEnterpriseAgentPrompt(input: EnterprisePromptInput): string {
+  const backend = input.backend || {};
+  const company = value(input.companyName || input.companyId, '当前企业');
+  return [
+    '【Fmode 企业微服务 Agent 执行授权】',
+    '',
+    '你是当前用户的全栈开发与发布 Agent。用户已经开通 Fmode 企业微服务。先完整读取并遵守下面的线上执行规范,再把用户后续提出的需求落地为可运行、可测试、可发布的闭环结果。不要只给方案或代码片段。',
+    `完整规范(必须先读取):${ENTERPRISE_RULES_URL}`,
+    `规则清单(空白环境自动补齐):${ENTERPRISE_RULES_MANIFEST_URL}`,
+    '',
+    '本指令包含敏感授权。只允许写入本机配置并用于本次项目工作;禁止在回复、终端输出、日志、截图、提交记录或报告中回显完整 token、masterKey 或 session token。数据库操作必须通过 Parse API/SDK 配合 masterKey 完成,不得索取或使用数据库直连地址。',
+    `FMODE_NEWAPI_TOKEN=${value(input.newApiToken)}`,
+    `FMODE_PARSE_APP_ID=${value(backend.appId)}`,
+    `FMODE_PARSE_MASTER_KEY=${value(backend.masterKey)}`,
+    `FMODE_PARSE_SERVER_URL=${value(backend.serverURL)}`,
+    `FMODE_PARSE_FUNCTIONS_URL=${value(backend.functionsURL)}`,
+    `FMODE_ENTERPRISE_ID=${value(input.companyId)}`,
+    `FMODE_ENTERPRISE_NAME=${company}`,
+    `FMODE_SUBSCRIPTION_EXPIRE_AT=${value(input.expireAt, '按当前订阅状态')}`,
+    '',
+    '执行顺序:',
+    '1. 读取线上规范和当前项目 `.claude/` 规则;缺少规则时按 manifest 补齐,不覆盖用户已有规则。规则必须安装;按线上规范默认安装 5 个方法技能基线,fmode 音视频/VOC/提号技能仅按用户需求安装。',
+    '2. 将授权安全写入 `~/.fmode/config/user.json`、`~/.fmode/config/enterprise/current.json`;如当前项目已有 `.fmode/`,再写入项目配置并确保敏感文件被 `.gitignore` 忽略。优先调用本机 `POST /api/system/enterprise-runtime-config`;接口不可用时按线上规范直接原子落盘,不得因此停止。',
+    '3. 检查 Git 状态、Schema、前端、后端、`cloud/`、`api/` 和部署配置,把用户需求拆解并直接实现。',
+    '4. 遵守 Fmode 标准:FmodeParse、Parse Schema、多租户、Parse Claude Code、动态云函数、自定义 API、幂等、错误恢复和安全边界。',
+    '5. 完成静态检查、单元/模块测试、本地冒烟、生产构建;用户要求上线时通过 Parse/动态云函数和项目已有华为云 OBS/CDN 发布 API 或脚本完成发布并做线上回归,不要求用户进行服务器级运维。',
+    '6. 最终只报告功能、文件、测试、发布结果和风险,绝不回显任何完整敏感值。',
+    '',
+    '现在先完成配置和规则初始化,然后读取用户的具体需求并执行完整闭环。'
+  ].join('\n');
+}
+
+function maskSecret(secret: string): string {
+  const text = value(secret);
+  if (text === '(未提供)') return text;
+  if (text.length <= 10) return `${text.slice(0, 2)}***`;
+  return `${text.slice(0, 6)}***${text.slice(-4)}`;
+}
+
+export function maskEnterpriseAgentPrompt(prompt: string): string {
+  return String(prompt || '').replace(
+    /^(FMODE_NEWAPI_TOKEN|FMODE_PARSE_MASTER_KEY)=(.*)$/gm,
+    (_match, key, secret) => `${key}=${maskSecret(secret)}`
+  );
+}

+ 529 - 0
src/app/enterprise-trial.component.ts

@@ -0,0 +1,529 @@
+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="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{width:100%;min-height:40px;margin-top:10px;border:1px solid #25b983;border-radius:6px;background:#25b983;color:#06140f;font-size:12px;font-weight:800}.copy-button:hover{background:#35ca94}
+    .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 页面中重试。');
+    }
+  }
+
+  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,请先完成账户同步');
+      const prompt = buildEnterpriseAgentPrompt({
+        backend,
+        newApiToken,
+        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> {
+    const headers: Record<string, string> = {
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${this.sessionToken}`,
+      'X-Parse-Session-Token': this.sessionToken
+    };
+    const response = await fetch(`${API_BASE}${path}`, {
+      method,
+      headers,
+      cache: 'no-store',
+      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
+    };
+  }
+}

+ 10 - 6
src/app/login-modal.component.ts

@@ -20,19 +20,19 @@ const APP_ID = 'ncloudmaster';
             </svg>
           </div>
           <h1 class="logo-title">BRAIN<span>HACK</span></h1>
-          <p class="logo-subtitle">{{ qiweiSkill ? '登录后领取 7 天企微数字员工试用' : skillInstall ? '登录后开启 VOC 数据洞察' : '登录后继续' }}</p>
+          <p class="logo-subtitle">{{ qiweiTrial ? '登录后领取 7 天企业微服务试用' : qiweiSkill ? '登录后领取 7 天企微数字员工试用' : skillInstall ? '登录后开启 VOC 数据洞察' : '登录后继续' }}</p>
         </div>
 
         <div
           class="purchase-summary"
           *ngIf="skillInstall"
-          [attr.aria-label]="qiweiSkill ? '企业微信智能助手价格' : 'VOC 数据洞察体验价格'"
+          [attr.aria-label]="qiweiTrial ? '企业微服务试用' : qiweiSkill ? '企业微信智能助手价格' : 'VOC 数据洞察体验价格'"
         >
           <div>
-            <span>{{ qiweiSkill ? '企业微信智能助手' : 'VOC 数据洞察体验' }}</span>
-            <p>{{ qiweiSkill ? '手机号登录后接收验证码,领取 1 个试用席位' : '支付金额全部成为可用数据额度' }}</p>
+            <span>{{ qiweiTrial ? '企业微服务' : qiweiSkill ? '企业微信智能助手' : 'VOC 数据洞察体验' }}</span>
+            <p>{{ qiweiTrial ? '手机号登录后填写姓名,自动开通 7 天试用' : qiweiSkill ? '手机号登录后接收验证码,领取 1 个试用席位' : '支付金额全部成为可用数据额度' }}</p>
           </div>
-          <strong>{{ qiweiSkill ? '7 天免费' : '¥29.9' }}</strong>
+          <strong>7 天免费</strong>
         </div>
 
         <!-- 登录表单 -->
@@ -95,7 +95,7 @@ const APP_ID = 'ncloudmaster';
           <button type="submit" class="btn-submit" [disabled]="!canSubmit() || isLoading()">
             <span *ngIf="isLoading()" class="loading-spinner"></span>
             <span *ngIf="isLoading()">登录中...</span>
-            <span *ngIf="!isLoading()">{{ qiweiSkill ? '登录并领取试用' : skillInstall ? '登录并继续开通' : '立即登录' }}</span>
+            <span *ngIf="!isLoading()">{{ qiweiTrial ? '登录并领取试用' : qiweiSkill ? '登录并领取试用' : skillInstall ? '登录并继续开通' : '立即登录' }}</span>
           </button>
         </form>
 
@@ -268,6 +268,7 @@ export class LoginModalComponent {
   @Input() visible = false;
   @Input() skillInstall = false;
   @Input() qiweiSkill = false;
+  @Input() qiweiTrial = false;
   @Output() loginSuccess = new EventEmitter<{ userId: string; sessionToken: string; displayName: string }>();
   phone = '';
   code = '';
@@ -365,6 +366,7 @@ export class LoginModalComponent {
       localStorage.setItem('apig_session_token', sessionToken);
       localStorage.setItem('apig_user_id', userData.objectId);
       localStorage.setItem('apig_display_name', displayName);
+      localStorage.setItem('apig_phone', String(result.data.mobile || userData.mobile || userData.mobilePhoneNumber || this.phone));
 
       console.log('[Login] userId:', userData.objectId, 'display:', displayName);
       this.loginSuccess.emit({ userId: userData.objectId, sessionToken, displayName });
@@ -396,6 +398,8 @@ export class LoginModalComponent {
       const userData = await resp.json();
       if (userData.objectId) {
         console.log('[Login] 已有登录用户:', userData.objectId);
+        const cachedPhone = String(userData.mobile || userData.mobilePhoneNumber || '').trim();
+        if (cachedPhone) localStorage.setItem('apig_phone', cachedPhone);
         return { userId: userData.objectId, sessionToken: token };
       }
       // token 失效,清除缓存