AI对话+React 项目实战(半成品)

在开始前的构思...

结合这段时间在学的 React 和 js ,我想着干脆自己搭个项目试试看。说是项目,其实更像个「技术实验场」------ 把自己学习的前端技术和AI揉到一起,看看能不能碰撞出点不一样的东西。目前它还只是个半成品,很多功能还在打磨,但核心的思路已经很清晰了。

项目亮点是集成 LLM 功能,支持聊天、文生图、语言处理等智能交互;还有完整的旅游应用功能模块,包括首页、搜索、行程、收藏、优惠等。

有了大致方向,我们可以在网上找类似功能的APP进行模仿和参考。在浏览了市面上各类旅游APP后我觉得同程旅行的微信小程序的界面就很符合我的预期:

在程序底部的导航栏有 首页 ,定制旅行 , 里程商城 , 订单 , 我的 这五大板块。我们可以按照它的样式来构思项目结构。

在下面的项目搭建中,我们需要支持5个主要模块:首页特惠专区我的收藏行程个人中心 。每个模块对应不同的业务场景,而程序中的 AI问答功能 我们可以放在行程选项下。

技术栈规划

  1. 前端框架 我们选择 React,它的组件化特性特别适合构建交互式UI,像旅游客服的聊天界面、个人中心这些模块,拆分成组件后维护起来很方便。再搭配React Hooks管理状态,代码更简洁。
  2. 路由管理 我们就用 react-router-dom 处理页面跳转,通过标签配置的实现首页、旅游客服页、个人中心这些页面的切换。
  3. UI组件库 我们选择 react-vant ,这是专为移动端设计的组件库,响应式布局、主题定制都支持,而且体积小。项目里的底部导航栏(Tabbar)可以直接用的它的组件,能省不少样式开发时间。
  4. 样式处理 我们可以用CSS模块(.module.css)和原子类(如.flex、.flex-col)搭配。CSS模块能避免样式冲突,原子类提高了样式复用性。为了移动端适配还可以引入了 lib-flexible ,自动设置html的font-size,配合PostCSS的pxtorem插件把px转成rem 。
  5. AI服务 直接选择到官网申请 API Key 来调用大模型。

项目搭建

这是已经搭建好了的源代码根目录:

其中要搭建的模块有:

功能模块 🗂️

  • LLM/ : 包含与 AI 聊天相关的功能
  • hooks/ : 自定义 React Hooks

组件库 🧩

  • BlankLayout/ : 空白布局组件
  • MainLayout/ : 主布局组件

页面组件 📱

  • Home/ : 首页
  • Trip/ : 旅行相关页面(主要)
  • Account/ : 账户页面 (主要)
  • Collection/ : 收藏页面
  • Discount/ : 优惠页面
  • Login/ : 登录页面
  • Search/ : 搜索页面

实战

全局设置

1. 路由配置

jsx 复制代码
import './App.css'
import {
  Suspense,      // 用于懒加载组件的加载状态管理
  lazy           // 动态导入组件(懒加载)
} from 'react';
import {
  Routes,        // 路由配置组件
  Route,         // 单个路由规则
  Navigate       // 用于路由重定向
} from 'react-router-dom'
import MainLayout from '@/components/MainLayout'  // 带底部导航的主布局
import BlankLayout from '@/components/BlankLayout'  // 空白布局(无导航)

// 懒加载各页面组件(按需加载,提高首屏加载速度)
const Home = lazy(() => import('@/pages/Home'));
const Discount = lazy(() => import('@/pages/Discount'));
const Collection = lazy(() => import('@/pages/Collection'));
const Trip = lazy(() => import('@/pages/Trip'));
const Account = lazy(() => import('@/pages/Account'));
const Search = lazy(() => import('@/pages/Search'));

function App() {
  return (
    <>
      {/* 包裹路由以处理懒加载时的加载状态 */}
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          {/* 使用MainLayout的路由组(包含底部导航) */}
          <Route element={<MainLayout />}>
            {/* 根路径重定向到首页 */}
            <Route path="/" element={<Navigate to="/home" />}/>
            {/* 底部导航对应的五个主页面 */}
            <Route path="/home" element={<Home/>}/>
            <Route path="/discount" element={<Discount/>}/>
            <Route path="/collection" element={<Collection/>}/>
            <Route path="/trip" element={<Trip/>}/>
            <Route path="/account" element={<Account/>}/>
          </Route>
          
          {/* 使用BlankLayout的路由组(无导航,独立页面) */}
          <Route element={<BlankLayout />}>
            <Route path="/search" element={<Search />}/>
          </Route>
        </Routes>
      </Suspense>
    </>
  )
}

export default App

这里我们通过 React 的懒加载功能(lazySuspense)按需加载页面组件以优化性能,使用 react-router-dom 定义路由规则,将首页、特惠专区等页面纳入带底部导航的 MainLayout 布局,将搜索页纳入无导航的 BlankLayout 布局,并设置根路径重定向到首页,实现了应用页面的路由管理和布局区分。

2. 统一基础样式

css 复制代码
.flex {
  display: flex;        /* 使用Flexbox布局 */
}
.flex-col {
  flex-direction: column; /* 设置为垂直方向的Flex布局 */
}
.flex-1 {
  flex:1;              /* 子元素占据剩余空间的比例 */
}

/* 高度相关样式 */
.h-screen {
  height: 100vh;        /* 元素高度为视口高度 */
}
.h-all {
  height: 100%;         /* 元素高度为父容器高度 */
}

/* 定位相关样式 */
.fixed-loading{
  position: fixed;      /* 固定定位 */
  top: 50px;            /* 距离顶部50px */
  left: 50%;            /* 水平居中 */
  transform: translateX(-50%); /* 精确居中 */
  z-index: 9999;        /* 层级最高 */
}

/* 间距相关样式(使用移动端常用的4px基准) */
.mt2 {
  margin-top: 8px;      /* 顶部间距(2*4px) */
}
.mt3 {
  margin-top: 12px;     /* 顶部间距(3*4px) */
}
.ml4 {
  margin-left: 16px;    /* 左侧间距(4*4px) */
}

3. 实现移动端响应式布局

js 复制代码
export default {
  plugins: {
    "postcss-pxtorem": {
      rootValue: 75, // 以 iPhone6 为参考,1rem = 37.5px
      propList: ['*'], // 所有属性都转换
      exclude: /node_modules/i, // 排除 node_modules 中的文件
    },
  },
}

什么是 PostCSS 和 postcss-pxtorem?

  • PostCSS :一个 CSS 处理工具,通过插件机制转换 CSS 代码(如自动添加前缀、单位转换等)。
  • postcss-pxtorem :PostCSS 的插件之一,用于将 CSS 中的 px 单位自动转换为 rem 单位。

这段代码的目的是实现移动端响应式布局 ,通过 px 转 rem 的自动转换,结合 lib-flexible (在package.json 中引入)动态调整根元素字体大小,让页面在不同尺寸的移动设备上都能保持良好的显示效果,避免出现布局错乱或元素过大/过小的问题

底部导航栏功能与样式

底部导航栏在 MainLayout 组件中实现,我们使用 react-vantTabbar 组件和react-router-dom 进行路由导航。

jsx 复制代码
import {
    useState,      
    useEffect       
} from 'react';
import {
    Tabbar,          // 底部导航栏组件
} from 'react-vant';
import {
    HomeO,           // 首页图标
    Search,          // 搜索图标
    FriendsO,        // 收藏图标
    SettingO,        // 设置图标
    UserO            // 用户图标
} from '@react-vant/icons';
import {
    Outlet,          // 渲染子路由组件
    useNavigate,     // 用于编程式导航
    useLocation      // 获取当前路由信息
} from 'react-router-dom'

// 底部导航栏配置
const tabs = [
    { icon: <HomeO />, title: '首页', path: '/home'},
    { icon: <Search />, title: '特惠专区', path: '/discount'},
    { icon: <FriendsO />, title: '我的收藏', path: '/collection'},
    { icon: <SettingO />, title: '行程', path: '/trip'},
    { icon: <UserO />, title: '我的账户', path: '/account'}
]

// 主布局组件,包含底部导航和路由出口
const MainLayout = () => {
    const [active, setActive] = useState(0);  // 当前激活的导航索引
    const navigate = useNavigate();           // 导航钩子
    const location = useLocation();           // 当前路由位置钩子

    // 监听路由变化,更新导航激活状态
    useEffect(() => {
        console.log(location.pathname);  // 打印当前路径
        // 查找匹配当前路径的导航项索引
        const index = tabs.findIndex(
            tab => location.pathname.startsWith(tab.path)
        );
        console.log(index);  // 打印匹配的索引
        if (index !== -1) setActive(index);
    }, [location.pathname]);  // 依赖路径变化触发

    return (
        <div 
        className="flex flex-col h-screen"  // 整页高度的垂直布局
        style={{paddingBottom: '50px'}}     // 为底部导航留出空间
        >
            <div className="flex-1">        // 内容区域占满剩余空间
                <Outlet />                  // 渲染子路由组件
            </div>
            
            {/* 底部导航栏 */}
            <Tabbar value={active} onChange={
                (key) => { 
                    setActive(key);         // 更新激活状态
                    navigate(tabs[key].path);  // 导航到对应路径
                }
            }>
                {tabs.map((tab, index) => (
                    <Tabbar.Item 
                        key={index} 
                        icon={tab.icon}
                    > 
                    {tab.title}
                    </Tabbar.Item>
                ))}
            </Tabbar>
        </div>
    )
}

export default MainLayout;

在这里我们通过 react-vant 的 Tabbar 组件实现底部导航栏,结合 react-router-dom 的相关功能实现路由管理。组件中定义了包含首页、特惠专区等五个导航项的配置数组,通过 useState 管理激活状态,useEffect 监听路由变化以自动匹配对应导航项,点击导航项时会更新激活状态并跳转至对应路径,同时使用 Outlet 渲染子路由内容,整体采用 flex 布局确保内容区域与底部导航的合理显示。

实际效果:

AI问答功能

首先我们在项目根目录下创建 .env.local 文件,再把从官网拿到的 Key 放进去:

env.local 复制代码
VITE_DEEPSEEK_API_KEY=sk-dU...HdrBB(已省略)

然后我们在 LLM 文件夹下创建一个index.js文件:

js 复制代码
// Kimi AI的API接口地址
const KIM_CHAT_API_URL = 'https://api.moonshot.cn/v1/chat/completions';

export const chat = async (
    messages, 
    api_url = DEEPSEEK_CHAT_API_URL, 
    api_key = import.meta.env.VITE_DEEPSEEK_API_KEY,
    model = 'deepseek-chat'
) => {
    try {
        // 发送HTTP请求到AI API
        const response = await fetch(api_url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${api_key}`
            },
            body: JSON.stringify({
                model,
                messages,
                stream: false, // 不使用流式响应
            })
        });
        
        // 解析响应JSON数据
        const data = await response.json();
        
        // 提取回复内容并格式化返回
        return {
            code: 0, // 状态码,0表示成功
            data: {
                role: 'assistant',
                content: data.choices[0].message.content
            }
        };
    } catch (err) {
        // 错误处理
        console.error('请求出错:', err);
        return {
            code: 1, // 原代码此处有误,应改为非零值表示错误
            msg: '出错了...'
        };
    } 
};

export const kimiChat = async (messages) => {
    // 复用chat函数,指定Kimi AI的API地址和模型
    const res = await chat(
        messages,
        KIM_CHAT_API_URL,
        import.meta.env.VITE_KIMI_API_KEY,
        'moonshot-v1-auto'
    );
    return res;
};

export const generateAvatar = async (text) => {
    // 设计头像生成的提示词,引导AI生成奶龙风格的头像描述
    const prompt = `
    你是一位漫画设计师,需要为用户设计头像,主打日漫风格。
    用户的信息是${text}
    要求有个性,有设计感。
    `;
    return prompt;
};
  1. 基础设置:定义了 DeepSeek 和 Kimi 两个 AI 平台的 API 地址常量。
  2. 核心功能chat函数是核心接口,通过 fetch API 向 AI 平台发送 HTTP 请求,支持自定义 API 地址、密钥和模型。函数接收对话消息数组,返回 AI 的回复内容,并包含错误处理逻辑。
  3. 平台封装kimiChat函数是对chat的二次封装,专门用于调用 Kimi AI 的接口,简化了参数传递。
  4. 扩展功能generateAvatar函数用于生成头像设计的提示词,引导 AI 生成特定风格(日漫风格)的头像描述。

在这里通过index.js给组件提供了统一的调用接口,方便我们在应用中集成 AI 对话功能。

自定义 hook

js 复制代码
import {
    useEffect  
} from 'react'

// 自定义钩子:用于设置页面标题
export function useTitle(title) {
     // 当组件挂载时设置文档标题
     useEffect(() => {
        document.title = title  // 修改页面标题为传入的title值
     }, [])  
}
export default useTitle;  

写自定义 hook 是为了复用设置页面标题的逻辑,避免在多个组件中重复编写相同代码,提高代码可维护性。

模块化 css

account.module.css

css 复制代码
.container {
    background-color: #f5f5f5;
    height: 100%;
}
.user {
    background-color: #fff;
    padding: 20px 16px;
    display: flex;
    align-items: center;
}
.nickname {
    font-size: 36px;
    font-weight: 500;
}
.level{
    font-size: 28px;
    color: #999;
    margin-top: 8px;
}
.slogan{
    font-size: 24px;
    color: #ccc;
    margin-top: 8px;
}

因为需要展示用户头像、昵称、等级等信息,样式更偏向于卡片式布局和信息展示(如 .nickname 设置了较大的字号和字重, .slogan 使用灰色调区分层级)。

trip.module.css

css 复制代码
.chatArea {
    overflow-y: auto;
    padding: 12px;
    background-color: #f7f8fa;
}
.inputArea {
    padding: 16px;
    border-top: 1px solid #ddd;
    background: white;
}
.input {
    margin-right: 8px;
}
.messageLeft, .messageRight {
    max-width: 70%;
    padding: 8px 12px;
    margin: 12px 0;
    border-radius: 8px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.messageLeft {
    background-color: #fff;
}

.messageRight {
    background-color: #4fc08d;
    color: white;
    margin-left: 30%;
}

在聊天界面需要实现消息列表、输入框等交互元素,样式更注重对话气泡布局和交互反馈( 在 .messageLeft 和 .messageRight 分别定义了左右气泡的背景色、边距,.inputArea 设置了输入区域的边框和内边距)。

部分页面展示

1. Account 页面(我的)

jsx 复制代码
import useTitle from '@/hooks/useTitle'
import {
    useState
} from 'react';
import {
    Image,
    Cell,
    CellGroup,
    ActionSheet,
    Popup,
    Loading
} from 'react-vant'
import {
    ServiceO,
    FriendsO,
    StarO,
    SettingO,
    UserCircleO
} from '@react-vant/icons'
import {
    generateAvatar
} from '@/llm'
import styles from './account.module.css';

const Account = () => {
    const [userInfo, setUserInfo] = useState({
        nickname: '哈基米',
        level: '5级',
        slogan: '胖宝宝好胖好可爱',
        avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
    })
    useTitle("我的")
    const [showActionSheet, setShowActionSheet] = useState(false);
    const handleAction = async (e) => {
        console.log(e)
        if (e.type === 1) {
            // AI生成头像
            const text = 
                `昵称: ${userInfo.nickname},
                slogan: ${userInfo.slogan}`
            const newAvatar = await generateAvatar(text)
        }else if (e.type === 2) {
            // 图片上传
        }
    }
    const actions = [
        {
            name: 'AI生成头像',
            color: '#123123',
            type: 1
        },
        {
            name: '上传头像',
            color: '#ee0a24',
            type: 2
        }
    ]
    return (
        <div className={styles.container}>
            <div className={styles.user}>
                <Image 
                    round
                    width= "64px"
                    height="64px"
                    src={userInfo.avatar}
                    style={{cursor: 'pointer'}}
                    onClick={() => setShowActionSheet(true)}
                />
                <div className="ml4">
                    <div className={styles.nickname}>昵称:{userInfo.nickname}</div>
                    <div className={styles.level}>等级:{userInfo.level}</div>
                    <div className={styles.slogan}>签名:{userInfo.slogan}</div>
                </div>
            </div>
            <div className="mt3">
                <CellGroup inset>
                    <Cell title="服务" icon={<ServiceO />} isLink />
                </CellGroup>
                <CellGroup inset className="mt2">
                    <Cell title="收藏" icon={<StarO />} isLink />
                    <Cell title="朋友圈" icon={<FriendsO />} isLink />
                </CellGroup>

                <CellGroup inset className="mt2">
                <Cell title="设置" icon={<SettingO />} isLink />
                </CellGroup>
            </div>
            <ActionSheet
                visible={showActionSheet}
                actions={actions}
                cancelText='取消'
                onCancel={() => setShowActionSheet(false)}
                onSelect={(e) => handleAction(e)}
            >

            </ActionSheet>
        </div>
    )
}

export default Account

当用户点击头像时会弹出动作面板,可选择 "AI 生成头像" 或 "上传头像",其中 AI 生成头像功能会调用相关函数,基于用户昵称和签名生成头像。页面使用 react-vant 组件库构建 UI,通过状态管理控制动作面板的显示与隐藏。

实际效果:

2. Trip 页面(旅游智能客服)

jsx 复制代码
import { useState } from 'react';  
import {
    Button, Input, Loading, Toast  // 导入react-vant组件
} from 'react-vant'
import { ChatO, UserO } from '@react-vant/icons';  // 导入图标组件
import useTitle from '@/hooks/useTitle'  // 导入自定义标题钩子
import { chat } from '@/llm'  // 导入聊天API函数
import styles from './trip.module.css';  // 导入模块样式

const Trip = () => {
    useTitle('旅游智能客服')  // 设置页面标题
    
    // 管理输入框内容状态
    const [text, setText] = useState("");
    // 管理发送中状态
    const [isSending, setIsSending] = useState(false);
    // 管理消息列表状态(初始包含两条示例消息)
    const [messages, setMessages] = useState([
        { id: 1, content: '你好', role: 'user' },
        { id: 1, content: 'hello, I am your assistant~~', role: 'assistant' }
    ]);

    // 处理聊天发送逻辑
    const handleChat = async () => {
        if (text.trim() === "") {  // 检查输入是否为空
            Toast.info('内容不能为空');
            return;
        }
        
        setIsSending(true);  // 显示发送中状态
        setText("");  // 清空输入框
        
        // 添加用户消息到消息列表
        setMessages((prev) => [...prev, {
            id: prev.length + 1,
            content: text,
            role: 'user'
        }]);
        
        // 调用聊天API获取回复
        const newMessage = await chat([{
            role: 'user',
            content: text
        }]);
        
        // 添加AI回复到消息列表
        setMessages((prev) => [...prev, newMessage.data]);
        setIsSending(false);  // 隐藏发送中状态
    }

    return (
        <div className="flex flex-col h-all">
            {/* 聊天消息显示区域 */}
            <div className={`flex-1 ${styles.chatArea}`}>
                {messages.map((msg, index) => (
                    <div key={index} className={`flex ${msg.role === 'user' ? styles.messageRight : styles.messageLeft}`}>
                        {msg.role === 'assistant' ? <ChatO /> : <UserO />}  // 根据角色显示不同图标
                        <div className={styles.messageContent}>{msg.content}</div>
                    </div>
                ))}
            </div>
            
            {/* 输入区域 */}
            <div className={`flex ${styles.inputArea}`}>
                <Input
                    value={text}
                    onChange={(e) => setText(e)}
                    placeholder="请输入消息"
                    className={`flex-1 ${styles.input}`}
                />
                <Button disabled={isSending} type="primary" onClick={handleChat} >发送</Button>
            </div>
            
            {/* 发送中加载提示 */}
            {isSending && (<div className="fixed-loading"><Loading type="ball" /></div>)}
        </div>
    )
}

export default Trip

当用户输入内容并点击发送时,组件通过text状态获取输入数据,校验通过后立即将用户消息添加到messages数组,此时UI会自动更新并显示新发送的消息;同时,isSending状态切换为true,触发加载动画并禁用发送按钮以避免重复提交,在调用chat接口获取AI回复的过程中,所有交互状态均由isSending控制,当接口返回结果后,新的助手消息被追加到messages数组,UI再次自动更新展示回复内容,同时isSending重置为false,恢复发送按钮功能。

当用户在底部输入框中输入消息内容,点击右侧"发送"按钮提交;若输入框为空,弹出提示框"内容不能为空"

后续

目前这个项目才刚刚初步实现了一点点功能,仍然需要完善,篇幅有限,文章就先到这里结束了。

相关推荐
JSON_L19 分钟前
Vue 电影导航组件
前端·javascript·vue.js
爱编程的喵1 小时前
深入理解JSX:从语法糖到React的魔法转换
前端·react.js
xptwop2 小时前
05-ES6
前端·javascript·es6
海底火旺2 小时前
单页应用路由:从 Hash 到懒加载
前端·react.js·性能优化
Heo2 小时前
调用通义千问大模型实现流式对话
前端·javascript·后端
前端小巷子3 小时前
深入 npm 模块安装机制
前端·javascript·面试
深职第一突破口喜羊羊4 小时前
记一次electron开发插件市场遇到的问题
javascript·electron
cypking4 小时前
electron中IPC 渲染进程与主进程通信方法解析
前端·javascript·electron
西陵4 小时前
Nx带来极致的前端开发体验——借助playground开发提效
前端·javascript·架构
江城开朗的豌豆4 小时前
Element UI动态组件样式修改小妙招,轻松拿捏!
前端·javascript·vue.js