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,恢复发送按钮功能。

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

后续

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

相关推荐
阿珊和她的猫15 分钟前
autofit.js: 自动调整HTML元素大小的JavaScript库
开发语言·javascript·html
阿珊和她的猫5 小时前
v-scale-scree: 根据屏幕尺寸缩放内容
开发语言·前端·javascript
gnip10 小时前
vite和webpack打包结构控制
前端·javascript
烛阴12 小时前
前端必会:如何创建一个可随时取消的定时器
前端·javascript·typescript
萌萌哒草头将军12 小时前
Oxc 最新 Transformer Alpha 功能速览! 🚀🚀🚀
前端·javascript·vue.js
1024小神14 小时前
nextjs项目build导出静态文件
前端·javascript
parade岁月14 小时前
JavaScript 日期的奇妙冒险:当 UTC 遇上 el-date-picker
javascript
是一碗螺丝粉14 小时前
拯救你的app/小程序审核!一套完美避开审核封禁的URL黑名单机制
前端·javascript·微信小程序
Juchecar14 小时前
采用 Vue 3 实现单页应用(SPA)与本地数据存储方案
前端·javascript·vue.js
雲墨款哥16 小时前
JS算法练习-Day10-判断单调数列
前端·javascript·算法