FitKick 电商APP项目总结二

Search界面

这里有一个搜索历史运用LRU缓存算法,其实就是最近最少使用,新的就插入到最前面,如果超过了就删除最后一个搜索历史。我还将搜索框单独作为一个组件放在了Components里面作为一个通用组件,因为别的地方,比如主页也是需要用的。

界面大概就是这样的:

相关代码:

SearchBox.jsx

javascript 复制代码
import {
    ArrowLeft,
    Close,
} from '@react-vant/icons'
import { memo, useRef, useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import styles from './search.module.css'
import { debounce } from '@/utils'

const SearchBox = (props) => {
    // 从props接收参数
    const { handleQuery, onSearch, value, showBackButton = true, autoFocus = false } = props
    // 使用value初始化内部state
    const [query, setQuery] = useState(value || '')
    const queryRef = useRef(null);
    const clearQuery = () => {
        setQuery('')
        queryRef.current.value = '';
        queryRef.current.focus();
    }
    const navigate = useNavigate();
    const handleChange = (e) => {
        let val = e.currentTarget.value;
        setQuery(val);
    }
    // 防抖
    const handleQueryDebounce = useMemo(() => {
        return debounce(handleQuery, 500)
    }, [])
    useEffect(() => {
        handleQueryDebounce(query);
    }, [query])
    // 添加useEffect监听value变化
    useEffect(() => {
        if (value !== query) {
            setQuery(value || '');
            if (queryRef.current) {
                queryRef.current.value = value || '';
            }
        }
    }, [value])
    // 处理搜索按钮点击
    const handleSearchClick = () => {
        onSearch(query);
    }

    // 处理回车键搜索
    const handleKeyDown = (e) => {
        if (e.key === 'Enter' && query.trim()) {
            onSearch(query);
        }
    }
    // 添加自动聚焦的effect
    useEffect(() => {
        if (autoFocus && queryRef.current) {
            queryRef.current.focus();
        }
    }, [autoFocus])

    return (
        <div className={styles.wrapper}>
            {/* 条件渲染返回按钮 */}
            {showBackButton && (
                <ArrowLeft onClick={() => {
                    navigate(-1)
                }} />
            )}
            <div className={styles.searchGroup}>
                <div className={styles.searchContainer}>
                    <input
                        type="text"
                        className={styles.ipt}
                        placeholder="请输入想搜索的内容"
                        ref={queryRef}
                        onChange={handleChange}
                        onKeyDown={handleKeyDown}
                        // 设置输入框的值
                        value={query}
                    />
                    <Close onClick={clearQuery} className={styles.Close} />
                </div>
                <div
                    onClick={query.trim() ? handleSearchClick : undefined}
                    className={query.trim() ? styles.searchBtnActive : styles.searchBtnDisabled}
                >
                    搜索
                </div>
            </div>
        </div>
    )
}

export default memo(SearchBox)

search.module.css

css 复制代码
.wrapper {
    display: flex;
    align-items: center;
    padding: 0 12px;
    height: 80px;
    background: #eee;
}

.searchGroup {
    flex: 1;
    display: flex;
    align-items: center;
    margin: 0 10px;
}

.searchContainer {
    flex: 1;
    position: relative;
    margin-right: 8px;
    height: 36px;
}

.ipt {
    width: 100%;
    line-height: 36px;
    background: #eee;
    color: #333;
    font-size: 28px;
    outline: none;
    border: none;
    border-bottom: 1px solid #e4e4e4;
}

/* 搜索按钮样式 - 移除绝对定位 */
.searchBtnActive {
    padding: 6px 16px;
    background-color: #1677ff;
    color: white;
    border-radius: 4px;
    cursor: pointer;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: content-box;
}

.searchBtnDisabled {
    padding: 6px 16px;
    background-color: #d9d9d9;
    color: #bfbfbf;
    border-radius: 4px;
    cursor: not-allowed;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: content-box;
}

/* 关闭按钮样式 */
.Close {
    position: absolute;
    right: 8px;
    top: 50%;
    transform: translateY(-50%);
    z-index: 1;
    display: none;
}

.searchBtn:hover {
    color: #1890ff;
}

/* 修复 placeholder 样式选择器 */
.ipt::placeholder {
    color: #999;
}

.box:placeholder {
    color: #1f1f1f;
}

/* 当输入框有内容时显示关闭按钮 */
.searchContainer:has(input:not(:placeholder-shown)) .Close {
    display: block;
}

这个搜索页面实现了完整的搜索功能,包括:

  • 搜索框输入和自动聚焦
  • 实时搜索建议
  • 搜索历史记录管理(查看、删除)
  • 热门搜索推荐
  • 点击搜索项自动填充并执行搜索 组件使用了 Zustand 进行状态管理,结构清晰,功能完整,用户体验良好。页面根据搜索框是否有内容动态切换显示搜索历史/热门推荐和搜索建议列表,提高了空间利用率和用户体验。

search.jsx

ini 复制代码
import { useEffect } from 'react'
import { Tag, Space } from 'react-vant';
import { ClockO, DeleteO, } from '@react-vant/icons';
import SearchBox from '@/components/SearchBox'
import { useSearchStore } from '@/store/useSearchStore';
import styles from './search.module.css'
import HotListItems from '@/components/HotListItems';
import useTitle from '@/hooks/useTitle';

const SearchPage = () => {
    useTitle('搜索');
    const {
        query,
        searchHistory,
        suggestList,
        hotList,
        setQuery,
        setSuggestList,
        handleSearch,
        clearHistory,
        deleteHistoryItem,
        setHotList
    } = useSearchStore();

    useEffect(() => {
        setHotList();
    }, [setHotList])

    const handleQuery = (query) => {
        setQuery(query);
        if (!query) {
            return;
        }
        setSuggestList(query);
    }

    const suggestListStyle = {
        display: query === '' ? 'none' : 'block',
    }

    // 处理热门鞋子点击
    const handleHotItemClick = (name) => {
        setQuery(name);
        handleSearch(name);
    }

    // 处理搜索历史点击(修改现有函数)
    const enhancedHandleHistoryClick = (item) => {
        setQuery(item);
        handleSearch(item);
    }

    return (
        <div className={styles.container}>
            {/* 搜索栏 */}
            <div className={styles.searchWrapper}>
                <SearchBox
                    handleQuery={handleQuery}
                    onSearch={handleSearch}
                    value={query}
                    autoFocus={true}  // 搜索页面自动聚焦
                />
            </div>
            {/* 搜索历史和热门推荐 - 仅当query为空时显示 */}
            {query === '' && (
                <div className={styles.contentWrapper}>
                    {/* 搜索历史 */}
                    <div style={{ marginTop: '24px' }}>
                        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
                            <span style={{ fontSize: '16px', fontWeight: '500' }}>搜索历史</span>
                            <DeleteO fontSize="16px" onClick={clearHistory} />
                        </div>

                        <Space wrap>
                            {searchHistory.map((item) => (
                                <Tag
                                    key={item}
                                    round
                                    type="primary"
                                    plain
                                    size="medium"
                                    onClose={() => deleteHistoryItem(searchHistory.indexOf(item))}
                                    closeable
                                    onClick={() => enhancedHandleHistoryClick(item)}
                                    style={{ cursor: 'pointer' }}
                                >
                                    <Space>
                                        <ClockO fontSize="14px" />
                                        {item}
                                    </Space>
                                </Tag>
                            ))}
                        </Space>
                    </div>
                    <div className={styles.wrapper}>
                        {/* 在 SearchPage 组件中使用 HotListItems 时传递 onItemClick 回调 */}
                        <HotListItems hotList={hotList} onItemClick={handleHotItemClick} />
                    </div>
                </div>
            )}
            {/* 搜索建议列表 - 当query不为空时全屏显示 */}
            <div className={styles.list} style={suggestListStyle}>
                {suggestList.map(item => (
                    <div key={item} className={styles.item}>
                        {item}
                    </div>
                ))}
            </div>
        </div>
    );
};

export default SearchPage

search.module.css

css 复制代码
.container {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    margin: auto;
    z-index: 100;
    overflow: hidden;
    background: #f2f3f4;
    display: flex;
    flex-direction: column;
}

.searchWrapper {
    position: relative;
    z-index: 10;
}

.contentWrapper {
    flex: 1;
    overflow-y: auto;
    padding: 16px;
}

.list {
    position: absolute;
    top: 80px;
    /* 与SearchBox高度一致 */
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    background: #fff;
    overflow-y: auto;
    z-index: 5;
}

.item {
    padding: 10px 20px;
    font-size: 24px;
    border-bottom: 1px solid #f0f0f0;
}

Coze界面

这个界面我就只做了一些相关功能,样式不是很好,搭建了一个coze的工作流,收集用户选择的信息,生成用户想要的鞋子图片,我是想让用户将喜欢的鞋子可以在这里修改一些样式或者颜色之类的,让用户可以完美得到想要鞋子的样子。也就是调用了一下工作流来实现。

代码实现:

ini 复制代码
import { useRef, useState } from 'react'
import useTitle from '@/hooks/useTitle'
import styles from './coze.module.css'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft } from '@react-vant/icons'

const Coze = () => {
    useTitle('鞋子图片生成')
    const navigate = useNavigate();

    const uploadUrl = 'https://api.coze.cn/v1/files/upload';
    const patToken = import.meta.env.VITE_PAT_TOKEN;
    const workflowUrl = 'https://api.coze.cn/v1/workflow/run';
    const workflow_id = '7534939677193175080';

    const uploadImageRef = useRef(null)
    const [imgPreviews, setImgPreviews] = useState(['https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png']);
    const [selectedColor, setSelectedColor] = useState('红色');
    const [shoeType, setShoeType] = useState('运动鞋');
    const [footType, setFootType] = useState('一双鞋');
    const [style, setStyle] = useState('写实');
    const [description, setDescription] = useState('');
    const [imgUrl, setImgUrl] = useState('');
    const [status, setStatus] = useState('');
    const [generatedDescription, setGeneratedDescription] = useState('');

    // 处理多张图片上传
    const handleFileUpload = (e) => {
        const files = e.target.files;
        if (!files || files.length === 0) return;

        // 如果是首次上传,清空初始预览图
        const newPreviews = imgPreviews.length === 1 && imgPreviews[0].includes('bearbobo.com') ? [] : [...imgPreviews];

        Array.from(files).forEach(file => {
            const reader = new FileReader();
            reader.readAsDataURL(file);
            reader.onload = (e) => {
                newPreviews.push(e.target?.result);
                setImgPreviews([...newPreviews]);
            };
        });
    }
    const handlePreviewClick = () => {
        uploadImageRef.current.click();
    }
    // 上传单张图片到Coze
    const uploadSingleFile = async (file) => {
        const formData = new FormData();
        formData.append('file', file);

        const res = await fetch(uploadUrl, {
            method: 'POST',
            headers: { 'Authorization': `Bearer ${patToken}` },
            body: formData,
        });

        const ret = await res.json();
        if (ret.code !== 0) {
            setStatus(ret.msg);
            return null;
        }
        return ret.data.id;
    }

    // 上传多张图片并获取file_ids
    const uploadFiles = async () => {
        const input = uploadImageRef.current;
        if (!input.files || input.files.length <= 0) return [];

        const fileIds = [];
        for (const file of input.files) {
            const fileId = await uploadSingleFile(file);
            if (fileId) {
                fileIds.push(fileId);
            }
        }
        return fileIds;
    }
    const handleRemoveImage = (index) => {
        // 如果是默认图片,不允许删除
        if (index === 0 && imgPreviews.length === 1 && imgPreviews[0].includes('bearbobo.com')) {
            return;
        }

        const newPreviews = [...imgPreviews];
        newPreviews.splice(index, 1);

        // 如果删除后没有图片,显示默认图片
        if (newPreviews.length === 0) {
            setImgPreviews(['https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png']);
        } else {
            setImgPreviews(newPreviews);
        }
    }
    // 生成图片
    const generate = async () => {
        setStatus("图片上传中...");
        const fileIds = await uploadFiles();
        // console.log(fileIds);
        if (fileIds.length === 0) return;

        setStatus("图片上传成功,正在生成...")
        const parameters = {
            // 传递多张图片的file_id
            shoe_pictures: JSON.stringify(fileIds.map(id => ({ file_id: id }))),
            color: selectedColor,
            shoe_type: shoeType,
            foot_type: footType,
            style: style,
            description: description
        };

        const res = await fetch(workflowUrl, {
            method: 'POST',
            headers: {
                Authorization: `Bearer ${patToken}`,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ workflow_id, parameters }),
        });

        const ret = await res.json();
        console.log(ret);
        if (ret.code !== 0) {
            setStatus(ret.msg);
            return;
        }

        // 解析返回结果
        const data = JSON.parse(ret.data);
        setStatus('');
        setImgUrl(data.image_url);
        setGeneratedDescription(data.description);
    }

    return (
        <div className={styles.container}>
            <div className={styles.backButton}>
                <ArrowLeft fontSize={27} opacity={0.5} onClick={() => navigate(-1)} />
            </div>
            <h1 className={styles.title}>鞋子图片生成器</h1>
            <div className={styles.input}>
                <div className={styles.fileInput}>
                    <input
                        ref={uploadImageRef}
                        type="file"
                        id="image"
                        name="image"
                        accept="image/*"
                        multiple
                        onChange={handleFileUpload}
                    />
                    <p>请上传一张或多张鞋子照片</p>
                </div>

                {/* 图片预览区域 */}
                <div className={styles.previewContainer} onClick={handlePreviewClick}>
                    {imgPreviews.map((preview, index) => (
                        <div key={index} className={styles.previewWrapper}>
                            <img
                                src={preview}
                                alt={`preview-${index}`}
                                className={styles.preview}
                                style={{ cursor: 'pointer' }}
                            />
                            {/* 非默认图片才显示删除按钮 */}
                            {!(index === 0 && imgPreviews.length === 1 && preview.includes('bearbobo.com')) && (
                                <button
                                    className={styles.deleteButton}
                                    onClick={(e) => {
                                        e.stopPropagation(); // 阻止事件冒泡
                                        handleRemoveImage(index);
                                    }}
                                >
                                    ×
                                </button>
                            )}
                        </div>
                    ))}
                </div>

                <div className={styles.settings}>
                    <div className={styles.selection}>
                        <label>鞋子颜色:</label>
                        <select value={selectedColor} onChange={(e) => setSelectedColor(e.target.value)}>
                            <option value="红色">红色</option>
                            <option value="蓝色">蓝色</option>
                            <option value="黑色">黑色</option>
                            <option value="白色">白色</option>
                            <option value="绿色">绿色</option>
                            <option value="黄色">黄色</option>
                        </select>
                    </div>

                    <div className={styles.selection}>
                        <label>鞋子类型:</label>
                        <select value={shoeType} onChange={(e) => setShoeType(e.target.value)}>
                            <option value="运动鞋">运动鞋</option>
                            <option value="休闲鞋">休闲鞋</option>
                            <option value="皮鞋">皮鞋</option>
                            <option value="高跟鞋">高跟鞋</option>
                            <option value="靴子">靴子</option>
                        </select>
                    </div>

                    <div className={styles.selection}>
                        <label>鞋子数量:</label>
                        <select value={footType} onChange={(e) => setFootType(e.target.value)}>
                            <option value="一双鞋">一双鞋</option>
                            <option value="左脚鞋">左脚鞋</option>
                            <option value="右脚鞋">右脚鞋</option>
                        </select>
                    </div>

                    <div className={styles.selection}>
                        <label>风格:</label>
                        <select value={style} onChange={(e) => setStyle(e.target.value)}>
                            <option value="写实">写实</option>
                            <option value="卡通">卡通</option>
                            <option value="国漫">国漫</option>
                            <option value="像素">像素</option>
                            <option value="水彩">水彩</option>
                        </select>
                    </div>

                    <div className={styles.selection}>
                        <label>额外描述:</label>
                        <textarea
                            value={description}
                            onChange={(e) => setDescription(e.target.value)}
                            placeholder="输入额外的描述信息..."
                        />
                    </div>
                </div>

                <div className={styles.generate}>
                    <button className={styles.generateButton} onClick={generate}>生成鞋子图片</button>
                </div>
            </div>

            <div className={styles.output}>
                <h2>生成结果</h2>
                <div className={styles.generated}>
                    {imgUrl ? (
                        <img src={imgUrl} alt="生成的鞋子" />
                    ) : status ? null : (
                        <p>请点击生成按钮开始生成</p>
                    )}
                    {status && <div className={styles.status}>{status}</div>}
                </div>

                {generatedDescription && (
                    <div className={styles.description}>
                        <h3>图片介绍</h3>
                        <p>{generatedDescription}</p>
                    </div>
                )}
            </div>
        </div>
    )
}

export default Coze

css:

css 复制代码
.container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    padding: 70px;
    background-color: #f5f5f5;
    font-family: Arial, sans-serif;
}

.title {
    font-size: 50px;
    margin-bottom: 75px;
    color: #333;
}

.input {
    display: flex;
    flex-direction: column;
    width: 100%;
    max-width: 800px;
    margin-bottom: 75px;
}

.fileInput {
    margin-bottom: 0px;
}

.previewContainer {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-bottom: 50px;
}

.preview {
    max-width: 200px;
    max-height: 200px;
    object-fit: cover;
    border-radius: 8px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.settings {
    display: flex;
    flex-direction: column;
    gap: 35px;
    background-color: white;
    padding: 75px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    margin-bottom: 75px;
}

.selection {
    display: flex;
    flex-direction: column;
    gap: 35px;
}

.selection label {
    font-weight: bold;
    color: #555;
}

.selection select,
.selection textarea {
    padding: 37.5px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 37.5px;
}

.selection textarea {
    min-height: 100px;
    resize: vertical;
}

.generate {
    width: 100%;
    margin-top: 37.5px;
}

/* // 将全局button样式改为类选择器 */
.generateButton {
    padding: 37.5px;
    width: 100%;
    background-color: #4CAF50;
    color: white;
    border: none;
    border-radius: 4px;
    font-size: 37.5px;
    cursor: pointer;
    transition: background-color 0.3s;
}

.generateButton:hover {
    background-color: #45a049;
}

.output {
    width: 100%;
    max-width: 800px;
    background-color: white;
    padding: 75px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.generated {
    width: 100%;
    height: 400px;
    display: flex;
    justify-content: center;
    align-items: center;
    border: 1px dashed #ddd;
    border-radius: 8px;
    margin-bottom: 75px;
    overflow: hidden;
}

.generated img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
}

.status {
    color: #666;
    font-style: italic;
}

.description {
    margin-top: 37.5px;
    padding: 37.5px;
    background-color: #f9f9f9;
    border-radius: 4px;
}

.description h3 {
    margin-bottom: 37.5px;
    color: #444;
}

.description p {
    color: #666;
    line-height: 1.6;
}

.previewWrapper {
    position: relative;
    display: inline-block;
    margin: 0.5rem;
}

.deleteButton {
    position: absolute;
    top: 5px;
    right: 5px;
    background-color: rgba(255, 0, 0, 0.7);
    color: white;
    border: none;
    border-radius: 50%;
    width: 30px;
    height: 30px;
    font-size: 14px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
}

.deleteButton:hover {
    background-color: rgba(255, 0, 0, 1);
}

.backButton {
    position: absolute;
    top: 10px;
    left: 10px;
    background-color: rgba(0, 0, 0, 0.7);
    color: white;
    border: none;
    border-radius: 50%;
    width: 30px;
    height: 30px;
    font-size: 14px;
    cursor: pointer;
    display: flex;
    align-items: center;
    padding: 0;
}
  • 基础配置 :设置工作流执行接口 URL、从环境变量获取认证令牌(PAT Token)、定义工作流唯一 ID。
  • 文件上传 :先实现单文件上传函数,通过 FormData 形式提交文件到 Coze 文件上传接口,获取单个文件的 file_id;再通过批量上传函数收集所有上传文件的 file_ids。
  • 参数构造 :将用户选择的鞋子颜色、类型、数量、风格、额外描述,以及上传得到的 file_ids(需序列化为 JSON 字符串)整合成工作流所需的参数对象。
  • 工作流调用 :使用 fetch API 发送 POST 请求到工作流执行接口,携带认证令牌和参数,异步执行工作流。
  • 响应处理 :接收工作流返回的响应,解析结果数据,提取生成的图片 URL 和描述文本,更新到组件状态中以展示给用户。
  • 状态与错误管理 :在整个流程中实时更新处理状态(如上传中、生成中),并处理可能出现的错误(如文件上传失败、工作流执行异常),向用户反馈结果。

我搭建的工作流:

相关推荐
Jolyne_11 分钟前
树节点key不唯一的勾选、展开状态的处理思路
前端·算法·react.js
饺子不放糖12 分钟前
workspace:你真的会用吗?
前端
饺子不放糖15 分钟前
dependencies vs devDependencies:别再傻傻分不清,你的 package.json 可能早就"胖"了!
前端
Kevin@wust21 分钟前
axios的封装
前端·vue
teeeeeeemo23 分钟前
Ajax、Axios、Fetch核心区别
开发语言·前端·javascript·笔记·ajax
Juchecar24 分钟前
TypeScript对 null/undefined/==/===/!=/!== 等概念的支持,以及建议用法
javascript
柏成29 分钟前
基于 pnpm + monorepo 的 Qiankun微前端解决方案(内置模块联邦)
前端·javascript·面试
唐诗43 分钟前
VMware Mac m系列安装 Windws 11,保姆级教程
前端·后端·github
ZXT1 小时前
Chrome Devtool
前端
wycode1 小时前
web缓存问题的解决方案
前端