Next.js 前端架构深度剖析
深入理解 Dify 前端的 Next.js App Router、组件设计、状态管理和 API 调用
📖 内容概述
本文将深入剖析 Dify 前端的技术架构,包括 Next.js App Router 的使用、组件设计模式、状态管理方案(Zustand + SWR)、API 调用封装等核心内容。
🎯 学习目标
- 理解 Next.js App Router 架构
- 掌握 Dify 的组件设计模式
- 理解状态管理方案(Zustand + SWR + Context)
- 掌握 API 调用封装和错误处理
- 学习国际化方案(i18next)
- 理解 SSR 和 CSR 的使用场景
📂 源码路径
web/
├── app/ # Next.js App Router 目录 ⭐
│ ├── (commonLayout)/ # 通用布局
│ │ ├── app/ # 应用页面
│ │ ├── datasets/ # 知识库页面
│ │ └── plugins/ # 插件页面
│ ├── (shareLayout)/ # 分享布局
│ ├── components/ # 页面级组件
│ ├── layout.tsx # 根布局
│ └── page.tsx # 首页
│
├── components/ # 通用组件库 ⭐
│ ├── base/ # 基础组件
│ ├── app/ # 应用相关组件
│ ├── datasets/ # 知识库组件
│ ├── workflow/ # 工作流组件
│ └── header/ # 头部导航
│
├── service/ # API 调用层 ⭐
│ ├── apps.ts # 应用 API
│ ├── datasets.ts # 知识库 API
│ ├── use-apps.ts # React Hooks
│ └── base.ts # 基础请求
│
├── context/ # React Context ⭐
│ ├── app-context.tsx # 应用上下文
│ ├── provider-context.tsx # Provider 上下文
│ └── modal-context.tsx # 弹窗上下文
│
├── hooks/ # 自定义 Hooks
├── models/ # TypeScript 类型
├── i18n/ # 国际化
├── utils/ # 工具函数
└── public/ # 静态资源一、Next.js App Router 架构
1.1 App Router vs Pages Router
Dify 使用 Next.js 15 的 App Router(不是旧的 Pages Router),这是一个重大的架构升级。
主要区别:
| 特性 | Pages Router(旧) | App Router(新) |
|---|---|---|
| 路由方式 | 文件系统路由(pages/) | 文件系统路由(app/) |
| 布局 | _app.tsx + _document.tsx | layout.tsx |
| 数据获取 | getServerSideProps | Server Components |
| API 路由 | pages/api/ | app/api/ 或 Route Handlers |
| 客户端组件 | 默认 | 需要 'use client' |
| 服务端组件 | 不支持 | 默认 |
| 流式渲染 | 不支持 | 支持 Suspense |
| 性能 | 较慢 | 更快 |
1.2 目录结构设计
app/
├── layout.tsx # 根布局(所有页面共享)
├── page.tsx # 首页
├── loading.tsx # 加载状态
├── error.tsx # 错误页面
│
├── (commonLayout)/ # 路由组(共享布局)
│ ├── layout.tsx # 通用布局
│ ├── app/ # 应用管理
│ │ ├── page.tsx # /app - 应用列表
│ │ └── [appId]/ # /app/:appId - 应用详情
│ │ ├── page.tsx
│ │ ├── overview/ # /app/:appId/overview
│ │ ├── configuration/
│ │ └── logs/
│ │
│ └── datasets/ # 知识库管理
│ ├── page.tsx # /datasets - 知识库列表
│ └── [datasetId]/ # /datasets/:datasetId
│
├── (shareLayout)/ # 分享布局(不同的导航栏)
│ ├── layout.tsx
│ ├── chat/[token]/ # /chat/:token - 分享聊天
│ └── completion/[token]/ # /completion/:token
│
├── signin/ # /signin - 登录页
│ └── page.tsx
│
└── api/ # API 路由(可选)
└── auth/
└── route.ts路由组的作用:
- 用括号包裹的目录(如
(commonLayout))不会出现在 URL 中 - 用于组织共享相同布局的路由
- 每个路由组可以有自己的
layout.tsx
1.3 布局系统
根布局:app/layout.tsx
typescript
// app/layout.tsx
import { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Dify - 开源 LLM 应用开发平台',
description: '构建和运营生成式 AI 原生应用',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
{/* 提供全局上下文 */}
<Providers>
{children}
</Providers>
</body>
</html>
)
}通用布局:app/(commonLayout)/layout.tsx
typescript
// app/(commonLayout)/layout.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Header from '@/app/components/header'
import { useAppContext } from '@/context/app-context'
export default function CommonLayout({
children,
}: {
children: React.ReactNode
}) {
const router = useRouter()
const { isLoggedIn } = useAppContext()
// 未登录重定向到登录页
useEffect(() => {
if (!isLoggedIn) {
router.push('/signin')
}
}, [isLoggedIn, router])
return (
<div className="flex h-screen">
{/* 侧边栏 */}
<aside className="w-64 bg-gray-100">
<Header />
</aside>
{/* 主内容区 */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}1.4 服务端组件 vs 客户端组件
服务端组件(默认):
typescript
// app/apps/page.tsx
// 默认是服务端组件(无需 'use client')
import { getApps } from '@/service/apps'
export default async function AppsPage() {
// 可以直接在服务端获取数据
const apps = await getApps()
return (
<div>
<h1>应用列表</h1>
{apps.map(app => (
<div key={app.id}>{app.name}</div>
))}
</div>
)
}客户端组件:
typescript
// app/components/app-card.tsx
'use client' // 必须声明
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function AppCard({ app }) {
const [isHovered, setIsHovered] = useState(false)
const router = useRouter()
// 可以使用 React Hooks 和浏览器 API
const handleClick = () => {
router.push(`/app/${app.id}`)
}
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleClick}
>
{app.name}
</div>
)
}何时使用客户端组件:
- ✅ 需要使用 React Hooks(useState, useEffect 等)
- ✅ 需要事件监听(onClick, onChange 等)
- ✅ 需要浏览器 API(localStorage, window 等)
- ✅ 需要使用第三方客户端库
何时使用服务端组件:
- ✅ 纯展示内容
- ✅ 直接访问后端资源(数据库、文件系统)
- ✅ 保护敏感信息(API Key 等)
- ✅ 减少客户端 JavaScript 体积
二、组件设计模式
2.1 组件分层
Dify 的组件分为三层:
components/
├── base/ # 第一层:基础组件(最底层)
│ ├── button/ # 按钮
│ ├── input/ # 输入框
│ ├── modal/ # 弹窗
│ ├── select/ # 下拉选择
│ └── icons/ # 图标
│
├── app/ # 第二层:业务组件(中间层)
│ ├── configuration/ # 应用配置
│ ├── log/ # 日志查看
│ └── annotation/ # 标注管理
│
└── header/ # 第三层:页面组件(最上层)
├── account-dropdown/
├── app-nav/
└── workspace-selector/分层原则:
基础组件:
- 纯 UI 组件,无业务逻辑
- 高度可复用
- 接受 props 控制行为
- 可以独立使用
业务组件:
- 包含业务逻辑
- 调用 API
- 管理状态
- 组合基础组件
页面组件:
- 完整的功能模块
- 路由级别的组件
- 组合业务组件和基础组件
2.2 基础组件示例
Button 组件:
typescript
// components/base/button/index.tsx
'use client'
import { forwardRef } from 'react'
import classNames from 'classnames'
export type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'small' | 'medium' | 'large'
loading?: boolean
disabled?: boolean
children: React.ReactNode
onClick?: () => void
className?: string
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
children,
onClick,
className,
},
ref
) => {
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700',
}
const sizeClasses = {
small: 'px-3 py-1.5 text-sm',
medium: 'px-4 py-2 text-base',
large: 'px-6 py-3 text-lg',
}
return (
<button
ref={ref}
className={classNames(
baseClasses,
variantClasses[variant],
sizeClasses[size],
className
)}
disabled={disabled || loading}
onClick={onClick}
>
{loading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
{/* Loading icon */}
</svg>
)}
{children}
</button>
)
}
)
Button.displayName = 'Button'
export default Button2.3 业务组件示例
AppCard 组件:
typescript
// components/app/app-card.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Trash2, MoreVertical } from 'lucide-react'
import Button from '@/components/base/button'
import Modal from '@/components/base/modal'
import { deleteApp } from '@/service/apps'
import { App } from '@/models/app'
type AppCardProps = {
app: App
onDelete?: () => void
}
export default function AppCard({ app, onDelete }: AppCardProps) {
const router = useRouter()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const handleClick = () => {
router.push(`/app/${app.id}/overview`)
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteApp(app.id)
onDelete?.()
setShowDeleteModal(false)
} catch (error) {
console.error('Failed to delete app:', error)
} finally {
setIsDeleting(false)
}
}
return (
<>
<div
className="p-4 border rounded-lg hover:shadow-lg transition-shadow cursor-pointer"
onClick={handleClick}
>
{/* 应用图标 */}
<div
className="w-12 h-12 rounded-lg mb-3"
style={{ backgroundColor: app.icon_background }}
>
<span className="text-2xl">{app.icon}</span>
</div>
{/* 应用信息 */}
<h3 className="text-lg font-semibold mb-1">{app.name}</h3>
<p className="text-sm text-gray-500 mb-3">
{app.mode === 'completion' ? '文本生成' : '对话助手'}
</p>
{/* 操作按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">
{new Date(app.created_at).toLocaleDateString()}
</span>
<button
className="p-1 hover:bg-gray-100 rounded"
onClick={(e) => {
e.stopPropagation()
setShowDeleteModal(true)
}}
>
<Trash2 className="w-4 h-4 text-gray-500" />
</button>
</div>
</div>
{/* 删除确认弹窗 */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="删除应用"
>
<p className="mb-4">确定要删除应用「{app.name}」吗?此操作无法撤销。</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => setShowDeleteModal(false)}
>
取消
</Button>
<Button
variant="danger"
loading={isDeleting}
onClick={handleDelete}
>
删除
</Button>
</div>
</Modal>
</>
)
}2.4 复合组件模式
typescript
// components/base/tabs/index.tsx
'use client'
import { createContext, useContext, useState } from 'react'
type TabsContextType = {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextType | null>(null)
// 主容器
export function Tabs({
defaultTab,
children
}: {
defaultTab: string
children: React.ReactNode
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
// Tab 列表
export function TabList({ children }: { children: React.ReactNode }) {
return (
<div className="flex border-b">
{children}
</div>
)
}
// 单个 Tab
export function Tab({
value,
children
}: {
value: string
children: React.ReactNode
}) {
const context = useContext(TabsContext)
if (!context) throw new Error('Tab must be used within Tabs')
const { activeTab, setActiveTab } = context
const isActive = activeTab === value
return (
<button
className={`px-4 py-2 ${isActive ? 'border-b-2 border-blue-600' : ''}`}
onClick={() => setActiveTab(value)}
>
{children}
</button>
)
}
// Tab 面板
export function TabPanel({
value,
children
}: {
value: string
children: React.ReactNode
}) {
const context = useContext(TabsContext)
if (!context) throw new Error('TabPanel must be used within Tabs')
const { activeTab } = context
if (activeTab !== value) return null
return <div className="p-4">{children}</div>
}
// 使用示例
function AppSettings() {
return (
<Tabs defaultTab="general">
<TabList>
<Tab value="general">通用设置</Tab>
<Tab value="model">模型配置</Tab>
<Tab value="prompt">Prompt 设置</Tab>
</TabList>
<TabPanel value="general">
通用设置内容
</TabPanel>
<TabPanel value="model">
模型配置内容
</TabPanel>
<TabPanel value="prompt">
Prompt 设置内容
</TabPanel>
</Tabs>
)
}三、状态管理
3.1 三种状态管理方案
Dify 使用三种状态管理方案的组合:
| 方案 | 用途 | 示例 |
|---|---|---|
| Zustand | 全局状态(跨页面) | 用户信息、工作空间 |
| SWR | 服务端数据(API) | 应用列表、知识库数据 |
| React Context | 组件树状态 | 主题、多语言、弹窗 |
3.2 Zustand 全局状态
创建 Store:
typescript
// context/app-context.tsx
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type User = {
id: string
name: string
email: string
}
type AppStore = {
// 状态
user: User | null
currentWorkspaceId: string | null
isLoggedIn: boolean
// Actions
setUser: (user: User) => void
setCurrentWorkspace: (workspaceId: string) => void
logout: () => void
}
export const useAppStore = create<AppStore>()(
persist(
(set) => ({
// 初始状态
user: null,
currentWorkspaceId: null,
isLoggedIn: false,
// 设置用户
setUser: (user) => set({
user,
isLoggedIn: true
}),
// 设置当前工作空间
setCurrentWorkspace: (workspaceId) => set({
currentWorkspaceId: workspaceId
}),
// 登出
logout: () => set({
user: null,
currentWorkspaceId: null,
isLoggedIn: false
}),
}),
{
name: 'app-storage', // localStorage key
partialize: (state) => ({
// 只持久化部分状态
currentWorkspaceId: state.currentWorkspaceId,
}),
}
)
)
// 使用示例
function Header() {
const { user, logout } = useAppStore()
return (
<div>
<span>欢迎,{user?.name}</span>
<button onClick={logout}>退出</button>
</div>
)
}3.3 SWR 数据获取
基础用法:
typescript
// service/use-apps.ts
import useSWR from 'swr'
import { getApps } from './apps'
import type { App } from '@/models/app'
export function useApps() {
const { data, error, isLoading, mutate } = useSWR<App[]>(
'/apps', // key
getApps, // fetcher
{
revalidateOnFocus: true, // 窗口聚焦时重新验证
revalidateOnReconnect: true, // 重连时重新验证
dedupingInterval: 5000, // 5秒内不重复请求
}
)
return {
apps: data,
isLoading,
isError: error,
refresh: mutate, // 手动刷新
}
}
// 在组件中使用
function AppList() {
const { apps, isLoading, isError, refresh } = useApps()
if (isLoading) return <div>加载中...</div>
if (isError) return <div>加载失败</div>
return (
<div>
<button onClick={() => refresh()}>刷新</button>
{apps?.map(app => (
<AppCard key={app.id} app={app} />
))}
</div>
)
}乐观更新:
typescript
// service/use-apps.ts
import useSWR from 'swr'
import { getApps, createApp, deleteApp } from './apps'
export function useApps() {
const { data, mutate } = useSWR('/apps', getApps)
// 创建应用(乐观更新)
const create = async (appData: CreateAppData) => {
// 乐观更新 UI(先更新,后请求)
const newApp = { id: 'temp-id', ...appData, created_at: new Date() }
mutate([...(data || []), newApp], false) // false = 不重新验证
try {
// 实际请求
const createdApp = await createApp(appData)
// 更新为真实数据
mutate([...(data || []), createdApp])
return createdApp
} catch (error) {
// 失败时回滚
mutate(data)
throw error
}
}
// 删除应用
const remove = async (appId: string) => {
// 乐观删除
mutate(data?.filter(app => app.id !== appId), false)
try {
await deleteApp(appId)
mutate() // 重新验证
} catch (error) {
mutate(data) // 回滚
throw error
}
}
return {
apps: data,
create,
remove,
}
}3.4 React Context
typescript
// context/modal-context.tsx
'use client'
import { createContext, useContext, useState } from 'react'
type ModalContextType = {
showPricingModal: boolean
setShowPricingModal: (show: boolean) => void
showAccountModal: boolean
setShowAccountModal: (show: boolean) => void
}
const ModalContext = createContext<ModalContextType | null>(null)
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [showPricingModal, setShowPricingModal] = useState(false)
const [showAccountModal, setShowAccountModal] = useState(false)
return (
<ModalContext.Provider
value={{
showPricingModal,
setShowPricingModal,
showAccountModal,
setShowAccountModal,
}}
>
{children}
</ModalContext.Provider>
)
}
export function useModalContext() {
const context = useContext(ModalContext)
if (!context) {
throw new Error('useModalContext must be used within ModalProvider')
}
return context
}
// 使用示例
function Header() {
const { setShowPricingModal } = useModalContext()
return (
<button onClick={() => setShowPricingModal(true)}>
查看定价
</button>
)
}四、API 调用封装
4.1 基础请求封装
typescript
// service/base.ts
import { toast } from 'react-hot-toast'
type RequestOptions = RequestInit & {
params?: Record<string, any>
needAllResponseContent?: boolean
}
class APIError extends Error {
code: string
status: number
constructor(message: string, code: string, status: number) {
super(message)
this.code = code
this.status = status
}
}
// 基础请求函数
async function request<T>(
url: string,
options: RequestOptions = {}
): Promise<T> {
const { params, needAllResponseContent, ...fetchOptions } = options
// 构建完整 URL
let fullUrl = `${process.env.NEXT_PUBLIC_API_URL}${url}`
// 添加查询参数
if (params) {
const searchParams = new URLSearchParams(params)
fullUrl += `?${searchParams.toString()}`
}
// 默认配置
const config: RequestInit = {
...fetchOptions,
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
credentials: 'include', // 携带 Cookie
}
// 添加 Token
const token = getToken()
if (token) {
config.headers = {
...config.headers,
'Authorization': `Bearer ${token}`,
}
}
try {
const response = await fetch(fullUrl, config)
// 处理 HTTP 错误
if (!response.ok) {
const error = await response.json()
throw new APIError(
error.message || 'Request failed',
error.code || 'UNKNOWN_ERROR',
response.status
)
}
// 解析响应
const data = await response.json()
// 返回完整响应或只返回数据
return needAllResponseContent ? data : data.data
} catch (error) {
// 错误处理
if (error instanceof APIError) {
// API 错误
toast.error(error.message)
throw error
} else {
// 网络错误
toast.error('网络请求失败')
throw new Error('Network error')
}
}
}
// GET 请求
export function get<T>(url: string, options?: RequestOptions): Promise<T> {
return request<T>(url, { ...options, method: 'GET' })
}
// POST 请求
export function post<T>(
url: string,
data?: any,
options?: RequestOptions
): Promise<T> {
return request<T>(url, {
...options,
method: 'POST',
body: JSON.stringify(data),
})
}
// PUT 请求
export function put<T>(
url: string,
data?: any,
options?: RequestOptions
): Promise<T> {
return request<T>(url, {
...options,
method: 'PUT',
body: JSON.stringify(data),
})
}
// DELETE 请求
export function del<T>(url: string, options?: RequestOptions): Promise<T> {
return request<T>(url, { ...options, method: 'DELETE' })
}
// Token 管理
function getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem('token')
}4.2 API 服务封装
typescript
// service/apps.ts
import { get, post, put, del } from './base'
import type { App, CreateAppData, UpdateAppData } from '@/models/app'
// 获取应用列表
export async function getApps(): Promise<App[]> {
return get<App[]>('/console/api/apps')
}
// 获取应用详情
export async function getApp(appId: string): Promise<App> {
return get<App>(`/console/api/apps/${appId}`)
}
// 创建应用
export async function createApp(data: CreateAppData): Promise<App> {
return post<App>('/console/api/apps', data)
}
// 更新应用
export async function updateApp(
appId: string,
data: UpdateAppData
): Promise<App> {
return put<App>(`/console/api/apps/${appId}`, data)
}
// 删除应用
export async function deleteApp(appId: string): Promise<void> {
return del<void>(`/console/api/apps/${appId}`)
}
// 复制应用
export async function duplicateApp(appId: string): Promise<App> {
return post<App>(`/console/api/apps/${appId}/duplicate`)
}4.3 SSE 流式请求
typescript
// service/completion.ts
export async function* streamCompletion(
appId: string,
data: CompletionData
): AsyncGenerator<CompletionChunk> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/console/api/apps/${appId}/completion-messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify(data),
}
)
if (!response.ok) {
throw new Error('Stream request failed')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) {
throw new Error('No reader available')
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解码数据
const chunk = decoder.decode(value, { stream: true })
// 解析 SSE 格式
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6) // 移除 "data: "
if (data === '[DONE]') {
return
}
try {
const parsed = JSON.parse(data)
yield parsed
} catch (e) {
console.error('Failed to parse SSE data:', e)
}
}
}
}
} finally {
reader.releaseLock()
}
}
// 使用示例
async function handleCompletion() {
const stream = streamCompletion(appId, { query: 'Hello' })
let fullText = ''
for await (const chunk of stream) {
if (chunk.event === 'message') {
fullText += chunk.answer
// 更新 UI
setMessage(fullText)
} else if (chunk.event === 'message_end') {
// 完成
console.log('Completed:', fullText)
}
}
}五、国际化(i18n)
5.1 i18next 配置
typescript
// i18n/config.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import zhHans from './locales/zh-Hans.json'
import enUS from './locales/en-US.json'
i18n
.use(LanguageDetector) // 自动检测语言
.use(initReactI18next) // 传递给 react-i18next
.init({
resources: {
'zh-Hans': { translation: zhHans },
'en-US': { translation: enUS },
},
fallbackLng: 'en-US',
interpolation: {
escapeValue: false, // React 已经安全了
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
})
export default i18n翻译文件:
json
// i18n/locales/zh-Hans.json
{
"common": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"confirm": "确认"
},
"app": {
"create": "创建应用",
"list": "应用列表",
"settings": "应用设置",
"delete_confirm": "确定要删除应用「{{name}}」吗?"
}
}5.2 使用翻译
typescript
// components/app/create-modal.tsx
'use client'
import { useTranslation } from 'react-i18next'
function CreateAppModal() {
const { t } = useTranslation()
return (
<Modal title={t('app.create')}>
<Input placeholder={t('app.name_placeholder')} />
<div className="flex gap-2">
<Button onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button onClick={handleCreate}>
{t('common.save')}
</Button>
</div>
</Modal>
)
}
// 带参数的翻译
function DeleteConfirmModal({ appName }: { appName: string }) {
const { t } = useTranslation()
return (
<Modal>
<p>{t('app.delete_confirm', { name: appName })}</p>
</Modal>
)
}六、实践项目
项目 1:创建一个自定义组件
目标:实现一个带搜索和过滤的列表组件
typescript
function AppListWithSearch() {
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<'all' | 'completion' | 'chat'>('all')
const { apps } = useApps()
const filteredApps = apps?.filter(app => {
const matchSearch = app.name.includes(search)
const matchFilter = filter === 'all' || app.mode === filter
return matchSearch && matchFilter
})
return (
<div>
<Input
placeholder="搜索应用"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Select value={filter} onChange={setFilter}>
<option value="all">全部</option>
<option value="completion">文本生成</option>
<option value="chat">对话</option>
</Select>
<div className="grid grid-cols-3 gap-4">
{filteredApps?.map(app => (
<AppCard key={app.id} app={app} />
))}
</div>
</div>
)
}项目 2:实现 SSE 流式聊天
项目 3:对比分析组件设计差异
📚 扩展阅读
🎓 自测题
- Next.js App Router 和 Pages Router 的主要区别是什么?
- 什么时候使用服务端组件?什么时候使用客户端组件?
- Dify 为什么同时使用 Zustand、SWR 和 Context?
- 如何实现乐观更新?
- 如何处理 SSE 流式响应?
- i18next 的工作原理是什么?
✅ 小结
关键要点:
- ✅ Next.js 15 + App Router 架构
- ✅ 三层组件设计(基础、业务、页面)
- ✅ 三种状态管理方案的组合使用
- ✅ 完善的 API 封装和错误处理
- ✅ SSE 流式响应实现
- ✅ 国际化方案
下一步:
学习进度: ⬜ 未开始 | 🚧 进行中 | ✅ 已完成