4.1 Next.js项目设置与SSR/CSR数据获取策略

欢迎来到第 4 章!在前面的章节中,我们已经使用 Nest.js 构建了一个稳健且功能丰富的 AI 后端服务(支持大模型调用、WebSocket 实时通信、BullMQ 异步队列等)。

现在,我们将视角转向前端,使用 React 框架的巅峰之作------Next.js (App Router) ,来构建一个现代化、响应迅速的用户界面。本节将结合我们的 ai-data-analyzer 项目,深入探讨前端的基础架构和数据获取策略。

源码地址:https://github.com/you-want/ai-data-analyzer

欢迎 star,支持一波。

1. Next.js 项目基础设置

我们的项目根目录下已经存在了一个 frontend 文件夹。这是一个基于 Next.js 15+ 和 React 19 构建的现代化工程。

打开 frontend/src/app/page.tsx,这是我们应用的主入口。在这个项目中,我们默认使用了 Tailwind CSS v4 进行样式编写,它与 Next.js 完美契合,使得布局和美化变得异常简单。

前端的目录结构遵循 App Router 的标准规范:

  • src/app/layout.tsx: 全局根布局,包含 HTML 骨架和全局字体、样式(如引入的 Geist 字体)。
  • src/app/globals.css: 全局样式文件,配置了 Tailwind 的基础变量和深色模式支持。
  • src/app/page.tsx: 首页路由组件。

2. 理解服务端渲染 (SSR) 与客户端渲染 (CSR)

Next.js 的核心优势在于它支持多种渲染和数据获取策略,允许开发者在性能和交互性之间找到最佳平衡。在 App Router 中,组件默认是服务端组件 (Server Components)

2.1 服务端组件 (SSR/SSG)

核心特点:代码在服务器端执行,生成的纯 HTML 发送给浏览器。

  • 优势
    • 加载极快:浏览器直接渲染 HTML,无需等待庞大的 JS bundle 下载。
    • SEO 友好:搜索引擎爬虫可以直接抓取完整的页面内容。
    • 安全性高:可以直接在组件里写数据库查询或安全的 API 调用,环境变量(如 API Key)不会泄露给浏览器。
    • 零客户端 JS 体积:服务端组件的代码不会被打包到客户端。
  • 适用场景:展示静态数据、初始加载时的分析报告概要、外层 Layout 布局。

在本项目中的应用示例

假设我们要在仪表盘页面展示后端的健康状态。

tsx 复制代码
// frontend/src/app/dashboard/page.tsx (这是一个 Server Component)
export default async function DashboardPage() {
  // 1. 在服务端直接获取数据
  // 注意:这里的 fetch 发生在 Node.js 环境中
  const res = await fetch('http://localhost:3001/analysis/status', { 
    cache: 'no-store' // 禁用缓存,每次请求都重新获取(SSR)
  });
  const statusText = await res.text();

  // 2. 直接渲染数据
  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">AI 数据仪表盘</h1>
      <div className="p-4 bg-gray-100 rounded-lg shadow">
        <p className="text-gray-700">后端服务状态: <span className="font-semibold text-green-600">{statusText}</span></p>
      </div>
    </div>
  );
}

2.2 客户端组件 (CSR)

核心特点:代码在用户的浏览器中执行,允许丰富的交互。

  • 优势 :支持 React 的所有特性,如状态管理 (useState)、副作用 (useEffect)、浏览器专属 API (window / document) 以及事件监听 (onClick, onChange)。
  • 适用场景:交互式图表(如 Echarts)、实时 WebSocket 通信、用户输入表单。

要将组件声明为客户端组件,必须 在文件顶部显式添加 "use client"; 指令。

在本项目中的应用示例

我们需要一个表单让用户输入提示词并提交给后端的结构化分析接口。

tsx 复制代码
// frontend/src/components/AnalysisForm.tsx
"use client"; // 声明为客户端组件

import { useState } from 'react';

export default function AnalysisForm() {
  const [prompt, setPrompt] = useState('');
  const [result, setResult] = useState<any>(null);
  const [loading, setLoading] = useState(false);
  
  const handleSubmit = async () => { 
    setLoading(true);
    try {
      // 这里的 fetch 发生在浏览器端
      const res = await fetch('http://localhost:3001/analysis/structured', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt })
      });
      const data = await res.json();
      setResult(data);
    } catch (error) {
      console.error("分析失败", error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="flex flex-col gap-4 max-w-xl mt-6">
      <textarea 
        className="w-full p-3 border rounded-md focus:ring-2 focus:ring-blue-500 text-black"
        rows={4}
        placeholder="例如:请分析2023年Q1销售额为100万,Q2销售额为150万的数据趋势"
        value={prompt} 
        onChange={(e) => setPrompt(e.target.value)} 
      />
      <button 
        className="bg-black text-white px-6 py-2 rounded-md hover:bg-gray-800 disabled:bg-gray-400"
        onClick={handleSubmit}
        disabled={loading || !prompt}
      >
        {loading ? 'AI 分析中...' : '开始智能分析'}
      </button>

      {/* 渲染后端返回的结构化 JSON 数据 */}
      {result && result.success && (
        <div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md text-black">
          <h3 className="font-bold mb-2">分析摘要:</h3>
          <p>{result.data.summary}</p>
          <p className="text-sm text-gray-500 mt-2">信心指数: {result.data.confidenceScore}</p>
        </div>
      )}
    </div>
  );
}

3. 数据获取策略的最佳实践 (在 AI Agent 项目中)

在我们的 AI 数据分析 Agent 这种复杂交互型应用中,单一的渲染模式是不够的,最佳策略是混合使用 (Mixed Rendering)

3.1 混合渲染策略

  1. 外层框架与静态配置使用 Server Components

    • 页面的 Layout、导航栏、侧边栏等骨架结构。
    • 初始的全局配置或用户基础信息(通过 Server Actions 或直接连库查询)。
    • 这保证了页面能"秒开",不会出现长时间的白屏。
  2. 核心业务区域使用 Client Components + SWR/React Query

    • 用户上传数据的表单组件。
    • 渲染 AI 结构化输出结果的卡片。
    • 基于 Echarts 的可视化图表组件。
    • 监听 WebSocket (/agent 命名空间) 实时打字机输出状态的区域。

Server Components 与 Client Components 的交响乐

在 Next.js 中,你可以将 Client Component 嵌入到 Server Component 中,从而完美融合两者的优势:

tsx 复制代码
// frontend/src/app/dashboard/page.tsx (Server Component)
import AnalysisForm from '@/components/AnalysisForm';

export default async function DashboardPage() {
  // 服务端获取基础状态 (极快)
  const res = await fetch('http://localhost:3001/analysis/status', { cache: 'no-store' });
  const statusText = await res.text();

  return (
    <div className="min-h-screen p-8 bg-white text-black">
      <header className="mb-8 border-b pb-4">
        <h1 className="text-3xl font-bold">AI 智能数据分析终端</h1>
        <p className="text-sm text-gray-500 mt-2">后端连接状态: {statusText}</p>
      </header>
      
      <main>
        <p className="text-gray-700 mb-4">请输入您的业务数据和分析需求,我们的 AI Agent 将为您生成专业洞察。</p>
        
        {/* 嵌入客户端组件,处理复杂的表单交互和客户端数据获取 */}
        <AnalysisForm />
      </main>
    </div>
  );
}

3.2 高阶数据获取:SWR 与 Server Actions

为了提升用户体验和开发效率,我们还会引入两个重要工具:

1. SWR (或 React Query) 进行客户端状态管理

在客户端轮询后端异步任务(BullMQ)状态时,原生的 fetchuseEffect 会产生大量样板代码。SWR 提供了优雅的解决方案:

tsx 复制代码
// frontend/src/components/TaskStatusViewer.tsx
"use client";

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(res => res.json());

export default function TaskStatusViewer({ jobId }: { jobId: string }) {
  // 每隔 2 秒自动轮询后端 /analysis/status/:jobId 接口
  const { data, error, isLoading } = useSWR(
    `http://localhost:3001/analysis/status/${jobId}`, 
    fetcher, 
    { refreshInterval: 2000 }
  );

  if (isLoading) return <div>加载中...</div>;
  if (error) return <div>获取状态失败</div>;

  return (
    <div>
      <p>当前任务状态:{data.status}</p>
      {data.status === 'completed' && <p>分析结果: {data.result}</p>}
    </div>
  );
}

2. Server Actions 简化表单提交

Next.js 的 Server Actions 允许你在客户端组件中直接调用服务端函数,无需手动编写中间的 API 路由,非常适合处理文件上传或简单的表单提交:

tsx 复制代码
// frontend/src/app/actions/upload.ts (Server Action)
'use server'

export async function uploadDataAction(formData: FormData) {
  // 这个函数运行在 Node.js 环境,可以直接调用后端微服务
  try {
    const res = await fetch('http://localhost:3001/data/upload/csv', {
      method: 'POST',
      body: formData,
    });
    
    if (!res.ok) {
      throw new Error(`后端响应异常: ${res.status}`);
    }
    
    return await res.json();
  } catch (error: unknown) {
    const errorMessage = error instanceof Error ? error.message : '未知错误';
    return { success: false, error: errorMessage };
  }
}
tsx 复制代码
// frontend/src/components/UploadForm.tsx
"use client";

import { useState } from 'react';
import { uploadDataAction } from '@/app/actions/upload';

interface UploadResponse {
  message?: string;
  rowCount?: number;
  preview?: Record<string, string>[];
  agentResult?: string;
  error?: string;
  success?: boolean;
}

export default function UploadForm() {
  const [message, setMessage] = useState('');
  const [uploadResult, setUploadResult] = useState<UploadResponse | null>(null);

  async function clientAction(formData: FormData) {
    setMessage('上传中...');
    setUploadResult(null);
    
    const result = await uploadDataAction(formData) as UploadResponse;
    
    if (result?.error || result?.success === false) {
      setMessage(`上传失败: ${result.error || '未知错误'}`);
    } else {
      setMessage('上传成功!');
      setUploadResult(result);
    }
  }

  return (
    <div className="flex flex-col gap-4 mt-6 max-w-xl">
      <form action={clientAction} className="flex flex-col gap-4">
        <div className="border border-dashed border-gray-300 p-4 rounded-md">
          <input type="file" name="file" accept=".csv,.json" />
        </div>
        <button type="submit" className="bg-black text-white px-6 py-2 rounded-md">
          上传数据 (Server Action)
        </button>
        {message && <p className="text-sm text-gray-600">{message}</p>}
      </form>

      {/* 展示解析结果预览 */}
      {uploadResult && uploadResult.preview && (
        <div className="mt-2 p-4 bg-gray-50 border border-gray-200 rounded-md">
          <h3 className="font-bold mb-2 text-sm">文件解析结果:</h3>
          <p className="text-sm mb-2">成功读取数据行数: <b>{uploadResult.rowCount}</b> 行</p>
          <div className="mt-3">
            <p className="text-xs font-semibold text-gray-500 mb-1">数据预览 (前3行):</p>
            <pre className="text-xs overflow-x-auto bg-gray-100 p-3 rounded">
              {JSON.stringify(uploadResult.preview, null, 2)}
            </pre>
          </div>
        </div>
      )}
    </div>
  );
}

3.3 后端跨域与接口支持补充

在打通前后端联调的过程中,我们还需要在 Nest.js 后端进行一些必要的配置和接口补充:

1. 开启 CORS 允许跨域

由于 Next.js 客户端组件运行在 localhost:3000,而 Nest.js 运行在 localhost:3001,必须在后端开启 CORS:

typescript 复制代码
// backend/src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { /*...*/ });

  // 开启 CORS 允许前端跨域请求
  app.enableCors({
    origin: 'http://localhost:3000', // 允许 Next.js 前端地址
    credentials: true,
  });
  
  // ...
}

2. 补充状态查询接口

为了配合前端 SWR 的轮询机制,我们需要在 AnalysisController 中实现带有任务 ID 参数的查询接口:

typescript 复制代码
// backend/src/analysis/analysis.controller.ts
import { Controller, Get, Param } from '@nestjs/common';

@Controller('analysis')
export class AnalysisController {
  // 模拟的查询任务状态接口 (用于 SWR 轮询)
  @Get('status/:id')
  getTaskStatus(@Param('id') id: string) {
    // 实际项目中应从 Redis 或数据库查询真实状态
    return {
      taskId: id,
      status: 'completed', 
      result: {
        summary: `任务 ${id} 的模拟分析结果`,
        confidenceScore: 0.95,
      },
    };
  }
}

通过灵活运用 SSR、CSR、SWR 以及 Server Actions,并打通后端的跨域与数据接口,我们的前端应用将具备极高的性能和极佳的用户体验。

下一节,我们将着手设计这个交互式仪表盘的具体布局,并集成强大的图表库来呈现数据!