特意去github找了一个用flask框架的项目,一起来学习它吧
这个系统包括很多功能:用户权限管理模块(管理员和普通用户),注册登录模块(滑块验证码功能),图书有关信息模块(借阅,收藏,详情),留言板模块,用户画像和个性化推荐模块
哇!真的是一个宝藏项目,一定要吃透它
我打算先前端,再后端
有三个按钮,前端代码在my-app/src/beforeLogin.js里面
- import { Form, Button, Layout, Menu, theme, Dropdown, Typography, Space, Table, Modal, Input, Card, Row, Col, Switch, Pagination } from "antd";
- import { Link, Navigate, useNavigate, Outlet } from "react-router-dom";
- import { LaptopOutlined, UserOutlined, BookOutlined, AppstoreOutlined, ExclamationCircleFilled } from '@ant-design/icons';
- import React, { useState, useEffect } from 'react';
- import axios from 'axios'
- import { Detail } from './bookdata'
-
- 可以看到他引入了一些ant design里面的一些组件
- 引入了一些React Router的组件和钩子(他用的是react18)
- Link:创建导航链接。
- Navigate:渲染时进行重定向。
- useNavigate:编程式导航。
- Outlet:嵌套路由内容的占位符。
- 引入axios来进行调用接口
- Detail 是什么呢?是来自bookdata的一个组件,可以点击跳转过去看具体的代码
- const Detail = (props)=>{
- let detail = props.data
-
- const [isModalOpen, setIsModalOpen] = useState(false);
-
- const showModal = () => {
- setIsModalOpen(true);
- };
- const handleOk = () => {
- setIsModalOpen(false);
- };
- const handleCancel = () => {
- setIsModalOpen(false);
- };
- return(
- <>
- <Button type="primary" onClick={showModal}>
- 详情
- </Button>
- <Modal open={isModalOpen} onOk={handleOk} onCancel={handleCancel} okText="确认" cancelText="取消">
- <Descriptions title="图书详细信息" bordered>
- <Descriptions.Item label="图书名称" span={2}>{detail.name}</Descriptions.Item>
- <Descriptions.Item label="图书作者">{detail.author}</Descriptions.Item>
- <Descriptions.Item label="出版社" span={2}>{detail.publish}</Descriptions.Item>
- <Descriptions.Item label="ISBN">{detail.isbn}</Descriptions.Item>
- <Descriptions.Item label="价格" span={2}>{detail.price}元</Descriptions.Item>
- <Descriptions.Item label="剩余数量">{detail.number}</Descriptions.Item>
- <Descriptions.Item label="内容简介" span={3}>{detail.intro}</Descriptions.Item>
- <Descriptions.Item label="出版日期">{detail.pubdate}</Descriptions.Item>
- <Descriptions.Item label="类别">{detail.type}</Descriptions.Item>
- </Descriptions>
- </Modal>
- </>
- )
- }
- 这段代码定义了一个名为 Detail 的 React 函数组件,用于展示图书的详细信息。当用户点击按钮时,会弹出一个模态框(Modal)显示图书的详细信息。
在登录前可以预览图书的图文信息
-
- 对了,我们该如何找到这个页面对应的代码在哪里呢,看网页的路径:http://127.0.0.1:5000/preview/books
- 然后去App.js里面找
-
- <Route path="/preview" element={<FrameForAll />}>
- <Route path="/preview/books" element={<BookPreview />} />
- <Route path="/preview/hotRanking" element={<PreviewHotRanking />} />
- </Route>
- 然后直接点击跳转,按住ctrl+点击(vscode中)
- 还是beforeLogin.js,不过是其中的BookPreview 组件
-
- const BookPreview = ()=>{
-
- const [bookData, setBookData] = useState([])
- const [savedata, setSaveData] = useState([])
-
- const [conponentshowstatus, setConponentShowStatus] = useState(true)
- const [current, setCurrent] = useState(1);
- const [pageSize, setPageSize] = useState(10);
-
- const {
- token: { colorBgContainer },
- } = theme.useToken();
-
- const baseUrl = 'http://127.0.0.1:5000/bookdata'
-
- useEffect(() => {
- // console.log('effect')
- axios.get(baseUrl).then(response => {
- const data = response.data
- // console.log(data)
- setBookData(data)
- setSaveData(data)
- })
- }, [])
- //只在第一次渲染时运行
-
- const handleChange = (e) => {
- const values = e.target.value
- // console.log(values)
- if (values) {
- // 这里保存一个原始数据,便于反复查找使用
- const filterdata = savedata.filter(item => {
- // console.log(item.value)
- return item.name.includes(values)
- })
- // console.log(filterdata)
- setBookData(filterdata)
- } else {
- setBookData(savedata)
- }
- }
-
- const onSwitchChange = (checked) => {
- setConponentShowStatus(!checked)
- }
-
- const onPageChange = (page) => {
- // console.log(page);
- setCurrent(page);
- };
-
- const handleShowSizeChange = (current, size) => {
- const newPage = Math.floor(start / size) + 1;
- // console.log(newPage,size)
- setPageSize(size);
- setCurrent(newPage);
- };
-
- // 对数据切片
- const start = (current - 1) * pageSize;
- const end = start + pageSize;
-
- const currentData = bookData.slice(start, end);
-
- return(
- <>
- {/* <p>此为预览页面,仅提供基本功能展示。若想体验完整功能,请先注册并登录!</p> */}
- <Search placeholder="输入书名" onChange={handleChange} enterButton style={{ width: 200, }} />
- <Switch checkedChildren="图片版" unCheckedChildren="文字版" onChange={onSwitchChange} className="switch" />
- <br/>
- <br />
- {/* 图片组件和文字组件 */}
- {
- conponentshowstatus ?
- <>
- <Row gutter={[8, 16]}>
- {
- currentData.map((item) => {
- return (
- <Col span={6} key={item.id}>
- <Card
- hoverable
- style={{
- width: 300,
- }}
- cover={<img alt="example" src={"http://127.0.0.1:5000/images/" + item.isbn + ".jpg"} />}
- actions={[
- <FakeBorrowCollect name="借阅"/>,
- <Detail data={item} />,
- <FakeBorrowCollect name="收藏" />
- ]}
- >
- <Meta title={item.name} description={item.author} />
- </Card>
- </Col>
- )
- })
- }
- </Row>
- <br />
- <Pagination className="pagination" current={current} showSizeChanger onShowSizeChange={handleShowSizeChange} onChange={onPageChange} total={bookData.length} />
- </>
- :
- <Table columns={columns} dataSource={bookData} locale={{ emptyText: '暂无数据' }} />
- }
- </>
- )
- }
一点点看,首先我们要搞清楚图书列表是怎么回事
- 1)首先是展示,页面这么多书的数据是从何而来,看下面的代码数据应该是来自于下面的代码
- useEffect(() => {
- axios.get(baseUrl).then(response => {
- const data = response.data;
- setBookData(data);
- setSaveData(data);
- });
- }, []);
- 然后设置? : 实现文字版和图片版的切换(根据 conponentshowstatus 状态决定显示图片版(用 Card 组件展示图书信息)还是文字版(用 Table 组件展示图书信息))
-
- 2)再说下搜索功能
- const handleChange = (e) => {
- const values = e.target.value
- // console.log(values)
- if (values) {
- // 这里保存一个原始数据,便于反复查找使用
- const filterdata = savedata.filter(item => {
- // console.log(item.value)
- return item.name.includes(values)
- })
- // console.log(filterdata)
- setBookData(filterdata)
- } else {
- setBookData(savedata)
- }
- }
- setBookData 更新 bookData 状态变量,bookData 用于存储和显示当前过滤后的图书数据。
- setSaveData 更新 savedata 状态变量,savedata 用于存储从服务器获取的原始图书数据,不直接显示,但用于搜索和过滤操作。
-
- 3)再说下分页
- const [current, setCurrent] = useState(1); // 当前页码
- const [pageSize, setPageSize] = useState(10); // 每页显示的条目数
- const onPageChange = (page) => {
- setCurrent(page); // 更新当前页码
- };
-
- const handleShowSizeChange = (current, size) => {
- setPageSize(size); // 更新每页显示的条目数
- setCurrent(1); // 更新 pageSize 时重置到第一页
- };
- 4)最后是前面说到的detail功能(详情展示)
- 点击详情出来一个弹窗
- const Detail = (props)=>{
- let detail = props.data
-
- const [isModalOpen, setIsModalOpen] = useState(false);
-
- const showModal = () => {
- setIsModalOpen(true);
- };
- const handleOk = () => {
- setIsModalOpen(false);
- };
- const handleCancel = () => {
- setIsModalOpen(false);
- };
- return(
- <>
- <Button type="primary" onClick={showModal}>
- 详情
- </Button>
- <Modal open={isModalOpen} onOk={handleOk} onCancel={handleCancel} okText="确认" cancelText="取消">
- <Descriptions title="图书详细信息" bordered>
- <Descriptions.Item label="图书名称" span={2}>{detail.name}</Descriptions.Item>
- <Descriptions.Item label="图书作者">{detail.author}</Descriptions.Item>
- <Descriptions.Item label="出版社" span={2}>{detail.publish}</Descriptions.Item>
- <Descriptions.Item label="ISBN">{detail.isbn}</Descriptions.Item>
- <Descriptions.Item label="价格" span={2}>{detail.price}元</Descriptions.Item>
- <Descriptions.Item label="剩余数量">{detail.number}</Descriptions.Item>
- <Descriptions.Item label="内容简介" span={3}>{detail.intro}</Descriptions.Item>
- <Descriptions.Item label="出版日期">{detail.pubdate}</Descriptions.Item>
- <Descriptions.Item label="类别">{detail.type}</Descriptions.Item>
- </Descriptions>
- </Modal>
- </>
- )
- }
- 5)整体布局方面(FrameForAll)
- 使用了 Ant Design 的 Layout 组件来创建一个包含 Header、Sider、Content 和 Footer 的布局。
- 主页面布局 (Layout):
- 最外层的 Layout 组件包含整个页面的布局。
- 头部 (Header):
- 使用了 Ant Design 的 Header 组件,类名为 header。
- 包含一个 Title 元素和一个自定义的 LoginRegisterButton 组件。
- Title 组件使用 level={3} 和 type="success" 来设置其样式。
- 侧边栏 (Sider):
- 宽度设置为 200 像素,背景色来自主题的 colorBgContainer。
- 包含一个 Menu 组件,设置了 inline 模式,默认选中的键为 ['1'],默认打开的子菜单为 ['sub1']。
- 主要内容区域 (Layout):
- 内部 Layout 设置了 padding 和 margin,确保内容区域有适当的间距。
- 包含 Content 和 Footer 两个子组件。
- 内容区域 (Content):
- 设置了 padding 和 margin,最小高度为 280 像素,背景色来自主题的 colorBgContainer,确保内容区域有滚动条(overflow: 'auto')。
- Outlet 组件用于显示嵌套路由的内容。
- 页脚 (Footer):
- 设置了居中对齐 (textAlign: 'center'),显示版权信息。
- 非常好
然后是热门排行页面
- const PreviewHotRanking = () => {
-
- return (
- <>
- <HotBorrow />
- <NewBook />
- <HotCollect />
- </>
- )
- }
- 看来是分三个组件
- 一个个看
- 1)HotBorrow
- 简单的调取接口,获取数据,展示
- 使用了 Ant Design 的 List 组件来显示热门借阅的图书数据
- 组件结构:
- Title 组件显示 "热门借阅" 标题。
- List 组件用于显示图书列表。
- itemLayout="vertical" 设置列表项为垂直布局。
- size="small" 设置列表项大小为小。
- dataSource={bookdata} 设置列表的数据源为状态变量 bookdata。
- footer 属性用于显示列表底部的额外内容。
- renderItem 属性用于自定义列表项的渲染。
- 2)NewBook HotCollect 和HotBorrow 也差不多一个道理,换了个布局
- NewBook
- 网格布局 (Row 和 Col):
- 使用 Ant Design 的 Row 组件创建水平网格布局。
- gutter={[16, 24]} 设置了列之间的水平间距和行之间的垂直间距。
- 使用 Col 组件来创建每个图书卡片的列。
- span={8} 设置每个图书卡片列的宽度占比。
- 图书卡片 (Card):
- 使用 Card 组件包裹每个图书。
- 设置 hoverable 属性,使得鼠标移动到卡片上时有浮动效果。
- 设置 cover 属性,用于显示图书的封面图片。
- actions 属性定义了操作按钮,包括借阅、详情和收藏。
- 使用 Meta 组件来显示图书的标题和作者。
- 3)HotCollect
- 同HotBorrow
随便逛逛差不多了,但是还有很多没有逛的,比如说借阅,收藏,管理员权限都需要登录才行
我们进入下一部分吧
- registerForm.js
- const RegisterForm = () => {
- const [form] = Form.useForm();
-
- const [isAlertShow, setAlertShow] = useState(false)
- const [isRegister,setRegister] = useState(false)
-
- // 注册post函数
- const ifRegister = async res => {
- const response = await axios.post(baseUrl, res)
- // console.log('response.data:',response.data)
- return response.data
- }
- const onFinish = async (values) => {
- console.log('Received values of form: ', values);
- try{
- const res1 = await ifRegister(values)
- // console.log('res1:', res1)
- setRegister(true)
- }catch(exception){
- // console.log(exception)
- setAlertShow(true)
- setTimeout(() => { setAlertShow(false) }, 3000)
- }
- };
- const prefixSelector = (
- <Form.Item name="prefix" noStyle>
- <Select
- style={{
- width: 70,
- }}
- >
- <Option value="86">+86</Option>
- <Option value="87">+87</Option>
- </Select>
- </Form.Item>
- );
-
- return (
- <>
- {isAlertShow ? <Alert
- className='registeralert'
- message="Error"
- description="用户名重复!"
- type="error"
- showIcon
- closable
- /> : ''}
- {isRegister?
- <Result
- status="success"
- title="注册成功!"
- extra={[
- <Link to="/login"><Button type="primary" key="console">前往登录</Button></Link>,
- ]}
- />
- :<Form
- {...formItemLayout}
- form={form}
- className="registerform"
- name="register"
- onFinish={onFinish}
- initialValues={{
- prefix: '86',
- }}
- scrollToFirstError
- >
- <Form.Item
- name="password"
- label="密码"
- rules={[
- {
- required: true,
- message: '请输入你的密码!',
- },
- ]}
- hasFeedback
- >
- <Input.Password />
- </Form.Item>
-
- <Form.Item
- name="confirm"
- label="确认密码"
- dependencies={['password']}
- hasFeedback
- rules={[
- {
- required: true,
- message: '请确认你的密码!',
- },
- ({ getFieldValue }) => ({
- validator(_, value) {
- if (!value || getFieldValue('password') === value) {
- return Promise.resolve();
- }
- return Promise.reject(new Error('两次输入的密码不一致!'));
- },
- }),
- ]}
- >
- <Input.Password />
- </Form.Item>
-
- <Form.Item
- name="nickname"
- label="用户名"
- tooltip="你想让其他人如何称呼你?"
- rules={[
- {
- required: true,
- message: '请输入你的用户名!',
- whitespace: true,
- },
- ]}
- >
- <Input />
- </Form.Item>
-
- <Form.Item
- name="phone"
- label="电话号码"
- rules={[
- {
- required: true,
- message: '请输入你的电话号码!',
- },
- ]}
- >
- <Input
- addonBefore={prefixSelector}
- style={{
- width: '100%',
- }}
- />
- </Form.Item>
-
- <Form.Item
- name="gender"
- label="性别"
- rules={[
- {
- required: true,
- message: '请选择你的性别!',
- },
- ]}
- >
- <Select placeholder="选择你的性别">
- <Option value="男性">男性</Option>
- <Option value="女性">女性</Option>
- <Option value="其他">其他</Option>
- </Select>
- </Form.Item>
-
- <Form.Item
- name="agreement"
- valuePropName="checked"
- rules={[
- {
- validator: (_, value) =>
- value ? Promise.resolve() : Promise.reject(new Error('应当同意服务条款!')),
- },
- ]}
- {...tailFormItemLayout}
- >
- <Checkbox>
- 注册即代表同意<a href="">服务条款</a>
- </Checkbox>
- </Form.Item>
- <Form.Item {...tailFormItemLayout}>
- <Button type="primary" htmlType="submit">
- 注册
- </Button>
- </Form.Item>
- </Form>}
- </>
- );
- };
-
- 使用 useState 钩子来管理是否显示警告信息和注册成功的状态。
- 定义了一个用于执行注册操作的异步函数 ifRegister,并在表单提交时调用。
- 组件结构:
- 如果 isAlertShow 为 true,则显示一个错误警告信息。
- 如果 isRegister 为 true,则显示注册成功的结果页面,否则显示注册表单。
- 注册表单包括密码、确认密码、用户名、电话号码、性别和服务条款同意复选框等字段。
- 使用 Ant Design 的 Form 组件包裹表单内容,设置表单的布局、名称和提交处理函数。
- 在表单中定义了各种输入框、密码框、下拉框和复选框,以及提交按钮。
- 我想我们得看一下后端代码了
- 1)首先是一个__init__.py文件
- from flask import Flask
- from flask_cors import CORS
- from flask_sqlalchemy import SQLAlchemy
- from flask_jwt_extended import JWTManager
- app=Flask('libraryms',template_folder="../templates",static_folder="../static")
- app.config.from_pyfile('settings.py')
- cors = CORS(app)
- db=SQLAlchemy(app)
- jwt = JWTManager()
- jwt.init_app(app)
- from libraryms import views,commands
- Flask('libraryms'): 创建一个名为 libraryms 的 Flask 应用实例。
- template_folder="../templates": 设置模板文件夹的路径。
- static_folder="../static": 设置静态文件夹的路径。
- 从 settings.py 文件中加载应用程序配置。
- 启用跨域资源共享(CORS),允许来自不同域的请求访问此 Flask 应用程序。
- 使用 SQLAlchemy 进行数据库操作并与 Flask 应用集成。
- 初始化 JWT 身份验证管理器并与 Flask 应用集成。
- 导入应用程序的视图和命令模块。确保在 libraryms 包中有 views.py 和 commands.py 文件。
-
- 2)setting.py
- import os
- from libraryms import app
- SQLALCHEMY_DATABASE_URI=os.getenv('DATABASE_URL')
- JWT_SECRET_KEY="super-secret"
-
- 将 SQLALCHEMY_DATABASE_URI 设置为环境变量 os.getenv('DATABASE_URL') 可以帮助保护敏感信息(如数据库连接字符串)不直接暴露在代码中。
- 3)commands.py
- 为flask build 填充预置数据做准备
- 4)models.py
- 为flask initdb 初始化数据库做准备
- 5)views.py
- 接口大集合
- 这里我们说注册接口
-
- @app.route("/register", methods=["POST"])
- @cross_origin()
- def register():
- sth = request.json
- # print(sth)
- username = sth['nickname']
- password = sth['password']
- gender = sth['gender']
- phone = sth['phone']
- # 判断用户名是否重合,普通用户能注册,所以检查普通用户表
- res1 = normalusr.query.filter(normalusr.username == username).first()
- if res1:
- return jsonify({"msg": "用户名重复!"}), 401
- else:
- # 普通表写入信息
- m1 = normalusr(username=username, password=password)
- db.session.add(m1)
- db.session.commit()
- # 信息表填入信息
- m2 = usrinfo(username=username,tel=phone,sex=gender)
- db.session.add(m2)
- db.session.commit()
- return jsonify({"msg": "注册成功!","ok":"true"}), 200
- 很简单,一目明了,加了一个判断,巧妙的是他用了models.py里面的
- # 普通用户表
- class normalusr(db.Model):
- nid=db.Column(db.Integer,primary_key=True,nullable=False,autoincrement=True)
- username=db.Column(db.String(30))
- password=db.Column(db.String(30))
- 这就大大方便了以后的编写,抽象
- 1)先看一下这个滑动验证码,之前我做过正常的那种验证码,就是输入数字和字母那种,用的是canvas
- <SliderCaptcha
- 初始化背景和拼图图像
- request={() =>
- createPuzzle(DemoImage).then(async (res) => {
- offsetXRef.current = res.x;
- await waitTime();
- return {
- bgUrl: res.bgUrl,
- puzzleUrl: res.puzzleUrl
- };
- })
- }
- 验证滑动位置
- onVerify={async (data) => {
- await waitTime();
- // console.log(data);
- if (data.x >= offsetXRef.current - 5 && data.x < offsetXRef.current + 5) {
- setDuration(data.duration);
- setVisible(true);
- await waitTime();
- setResult(true);
- offsetXRef.current = 0
- return Promise.resolve();
- }
- return Promise.reject();
- }}
- 显示滑块
- bgSize={{
- width: 250,
- height: 110
- }}
- mode="float"
- limitErrorCount={3}
- jigsawContent={
- visible && (
- <div className={"successTip"}>
- {Number((duration / 1000).toFixed(2))}秒内完成,打败了98%用户
- </div>
- )
- }
- actionRef={actionRef}
- />
- 请求背景和拼图图像
- SliderCaptcha 组件的 request 属性定义了获取背景图像和拼图图像的函数。
- createPuzzle(DemoImage) 是模拟生成拼图的函数,返回背景图和拼图的位置。
- 验证滑动位置
- onVerify 属性定义了验证滑动位置的函数。
- 用户滑动完成后,将实际滑动位置 data.x 与预期位置 offsetXRef.current 进行比较。如果在误差范围内(5个像素),则验证通过,否则验证失败。
- 显示滑块
- SliderCaptcha 组件通过 bgUrl 和 puzzleUrl 显示背景图和拼图。
- 滑块验证结果
- 如果验证通过,setDuration 和 setVisible 更新滑块验证的状态。
- 2)然后看看失败次数限制以及账户锁定功能
- 登录函数 onFinish 中的失败次数处理:
- 在 onFinish 函数中,如果失败次数超过2 (failAttempt > 1),会显示锁定消息并锁定账户。
- 锁定时长设置为31秒(const time = 31 * 1000;),并在本地存储中记录锁定时间。
- 登录失败时,失败次数增加,并显示相应警告消息。
-
- locked 状态用于指示账户是否被锁定。
- remainingTime 和 lockTime 用于计算和显示剩余锁定时间。
- useEffect 中的计时器每秒更新一次锁定状态,如果锁定时间到期,则解锁账户并重置失败次数。
登录进去发现,好多功能,用户画像和个性化推荐都有,我们一点点来,先看个人中心
- 个人中心的基本信息页面 self.js
- const Self = ()=>{
- const [info,setInfo] = useState({})
-
- // 获取用户信息
- const self = window.localStorage.getItem('loggedUser')
- const res = {"username":self}
- useEffect(() => {
- // console.log('self info')
- axios.get(baseUrl,{
- params:res
- }).then(response => {
- const data = response.data
- // console.log(data)
- setInfo(data)
- // console.log('info',data)
- })
- }, [])
- //只在第一次渲染时运行
-
- return(
- <>
- <Descriptions title="用户信息" bordered extra={<EditSelf data={info} handleChange={setInfo}/> }>
- <Descriptions.Item label="用户名" span={3}>{info.username}</Descriptions.Item>
- <Descriptions.Item label="电话号码" span={3}>{info.tel}</Descriptions.Item>
- <Descriptions.Item label="性别" span={3}>{info.sex}</Descriptions.Item>
- <Descriptions.Item label="简介" span={3}>{info.intro}</Descriptions.Item>
- </Descriptions>
- <br/>
- <EditPwd/>
- <br/>
- <br/>
- <IdeaRelease/>
- </>
- );
- }
- 它这个用户信息在登录后保存到localstorage中,修改密码的时候带过去,然后调用接口直接修改
- 后端接口有一个判断来确定是否是管理员,然后查询不同的表,这也是登陆时为什么有哪个选项的原因
- 个人用户 个性化推荐
- 1)用户画像
- 这里他还写了一个通用函数
- // 通用post函数
- const uniPost = async (url, res) => {
- const response = await axios.post(url, res)
- // console.log('response.data:',response.data)
- return response.data
- }
- 好事情
- 用户画像需要着重于后端代码,让我们看看他是如何实现的'http://127.0.0.1:5000/userprofile'
-
- # 用户画像
- @app.route('/userprofile', methods=["GET"])
- @cross_origin()
- def userprofile():
- sth = request.args
- username = sth['username']
- tagarray = []
- bookarray = []
- # 统计借阅历史
- res1 = bookBorrowHistory.query.filter(bookBorrowHistory.borrowusr == username).all()
- for x in res1:
- bookarray.append(x.name)
- # 统计收藏信息
- res2 = bookCollect.query.filter(bookCollect.username == username).all()
- for x in res2:
- res3 = bookitem.query.filter(bookitem.isbn == x.isbn).first()
- bookarray.append(res3.name)
- # 统计所有标签
- for x in bookarray:
- res4 = bookitem.query.filter(bookitem.name == x).first()
- tagarray.append(res4.type)
- # print(bookarray)
- # print(tagarray)
- # 计算出数量最多的3个标签
- dict = {}
- tag = []
- for x in tagarray:
- dict[x] = dict.get(x,0)+1
- # print(dict)
- if len(dict)>3:
- while (len(tag)<3):
- tag.append(max(dict,key=dict.get))
- dict.pop(max(dict,key=dict.get))
- else:
- for key in dict:
- tag.append(key)
- # print(tag)
- return tag
- 没有我想的那么复杂,没有用到模型,只是通过统计来实现生成用户标签
- 统计每个标签出现的次数,然后选取出现次数最多的前三个标签作为用户的标签信息。
-
- 2)个性推荐
- 收前端传递的包含用户标签信息的请求,根据这些标签信息生成推荐的图书清单,并将其作为 JSON 格式的响应返回给前端
- 根据类比返回罢了
- 普通用户心心念的收藏和借阅功能,这里的借阅有些复杂,涉及到还款什么的,先看收藏吧
- 这里有个问题:图书列表中已收藏和借阅的书籍在跳转页面后再次回到图书列表页面会不再显示已经借阅或者已经收藏(bookdata.js里面的BookList)
-
- 解决办法:在图书列表中保存收藏和借阅的状态:在获取图书列表时,同时获取每本书籍是否已被当前用户收藏或借阅的状态,并将这些状态存储在 bookData 中。
-
- 在图书列表加载时检查状态:在 useEffect 中获取图书数据时,额外获取用户的收藏和借阅信息,并更新每本书的状态。
-
- 在借阅和收藏操作后更新状态:当用户进行借阅或收藏操作时,更新 bookData 中对应书籍的状态。
- 需要加个状态,一会加一下
-
-
- 1.收藏 collect.js
- 1)搜索功能
- 搜索功能通过两个主要函数实现:onSearch 和 handleChange。
- onSearch:
- 当用户在搜索框中输入内容并点击搜索按钮时触发。
- 该函数接收用户输入的值作为参数。
- 它首先检查用户是否输入了内容,如果输入了内容,则使用 filter 方法过滤 collectdata 数组,只保留包含用户输入内容的项,并将过滤后的结果设置为新的 collectdata。
- 如果用户没有输入内容,则将 savedata(原始数据)设置回 collectdata,这样就重新
- handleChange:
- 当用户在搜索框中输入内容但不点击搜索按钮时触发(即在输入内容时实时搜索)。
- 它与 onSearch 相似,不同之处在于它是实时响应输入的变化。
- 它也首先检查用户是否输入了内容。如果输入了内容,则根据当前选择的搜索项 selectdata 使用 filter 方法过滤 savedata 数组,并将过滤后的结果设置为新的 collectdata。
- 如果用户没有输入内容,则将 savedata(原始数据)设置回 collectdata。
-
-
- 2)导出功能
- 首先,将表格数据 collectdata 进行清理,以确保导出的数据格式符合预期。在这里,将每一项的属性名称进行映射,并对价格属性进行调整,添加单位。
- 使用 XLSX.utils.json_to_sheet 方法将清理后的数据转换为 Excel 表格的工作表。
- 创建一个新的工作簿,并将工作表添加到该工作簿中。
- 使用 XLSX.writeFile 方法将工作簿写入到名为 "book.xlsx" 的 Excel 文件中。
- 在导出完成后,使用 Ant Design 的 message 组件显示导出成功的消息。
-
-
- 3)取消收藏功能,收藏功能
- CancelCollect 组件接收一个 data 属性,其中包含了要取消收藏的图书数据。
- 组件内部维护了一个 isCollect 状态,用于表示当前是否已收藏。默认为 true。
- 当用户点击按钮时,触发 info 函数,该函数首先获取当前用户信息和图书的 ISBN 号,然后通过 uniPost 函数向后端发送请求,告知服务器取消收藏。
- 如果取消成功,显示提示消息,并更新 isCollect 状态为 false,同时刷新页面以更新数据。
-
- 2.借阅功能 borrow.js
- 1)在 useEffect 钩子中,组件首次渲染时从后端获取借阅图书数据,并将其保存在 borrowdata 状态中。然后,根据 ischecking 属性筛选出待审核的借阅申请数据,保存在 applyborrowdata 状态中,以便显示在归还图书申请的表格中。
- 表格使用了 borrowColumns 和 applyBorrowColumns,这些列的配置与 collectColumns 类似,用于指定表格中各列的标题和渲染方式。
- 这段代码中的主要逻辑是:
- 通过 axios 发送请求获取借阅图书数据。
- 将数据保存到相应的状态变量中。
- 渲染两个表格,分别用于展示借阅图书和归还图书申请。
- 2)图书违约
- 获取数据后,计算每本书是否超期及超期天数和罚金:
- 当前时间戳通过 new Date().getTime() 获取。
- 遍历数据,判断是否已还书。如果已还书,根据归还日期计算借阅天数;否则,使用当前时间计算借阅天数。
- 判断借阅天数是否超过 31 天,计算超期天数和罚金。
- 过滤出所有超期的记录。
- 后端接口 http://127.0.0.1:5000/defaultdata
- 使用 Flask 框架定义了一个 API 路由 /defaultdata,接受 GET 请求。
- 从数据库中查询用户的违约记录,并返回一个包含违约记录的列表。
- messageBoard.js
- 1)评论
- 前端代码:
- MessageBoard 组件:主留言板组件,包含输入框、表情组件、图片上传组件,以及留言和评论显示。
- Comment 组件:递归渲染评论和子评论,并处理回复逻辑。
- handleReplySubmit 方法:处理子评论的提交,将新回复添加到对应的父评论中,并发送到服务器。
- handleClick 方法:处理新的父评论的提交,并发送到服务器。
- 后端代码:
- get_comments 方法:返回所有评论。
- add_comment 方法:根据评论类型(父评论或子评论)将新的评论或回复添加到相应的位置。
- 2)上传图片
-
- 前端实现:
- 文件上传组件:
- 使用 Ant Design 的 Upload 组件,用户可以通过点击按钮选择要上传的图片文件。
- 设置 action 属性为上传图片的后端接口地址。
- 通过 onChange 事件监听文件上传状态的变化,并根据上传结果进行相应的处理。
- 上传图片处理:
-
- 用户选择图片后,触发 handleChange 函数。
- 在 handleChange 函数中,根据文件上传状态(done、uploading、error),对上传的图片进行相应的处理。
- 如果文件上传成功(status 为 done),从响应中获取上传成功的图片路径,并将图片路径插入到留言内容中,以显示图片。
- 删除图片处理:
-
- 用户可以点击已上传图片的删除按钮,触发 handleRemovePics 函数。
- 在 handleRemovePics 函数中,向后端发送删除图片的请求,根据后端的响应决定是否删除前端对应的图片。
- 后端实现:
- 接收上传图片请求:
-
- 后端提供一个接口来接收前端上传图片的请求,一般是通过 POST 方法发送图片数据。
- 在 Flask 中,可以使用 Flask-Uploads 或直接处理 request.files 来接收上传的图片文件。
- 保存图片:
-
- 后端接收到图片文件后,根据需求将图片保存到服务器的指定目录中。
- 保存成功后,返回图片的访问路径给前端,以便前端显示上传成功的图片。
- 删除图片:
-
- 后端提供一个接口用于删除指定的图片文件,一般是通过 DELETE 方法发送图片文件名或路径。
- 在删除图片接口中,根据文件名或路径定位到要删除的图片文件,并删除它。
- selfadmin.js
- 1.读者管理组件
- 读者信息展示:
- 通过请求 /usrdata 接口获取读者信息数据,并将其显示在表格中。
- 如果没有读者信息,则表格显示暂无数据。
- 性别统计分析图:
- 通过请求 /usrsexdata 接口获取读者性别数据,并将其用饼图进行可视化展示。
- 饼图显示了不同性别的读者比例。
- 图书收藏信息展示及分析图:
- 通过请求 /usrcollectdata 接口获取读者收藏信息数据,并将其显示在表格中。
- 通过请求 /usrcollectanalysisdata 接口获取图书收藏排行数据,并将其用柱状图进行可视化展示。
- 柱状图显示了图书收藏量排名前十的图书信息。
- 图书借阅信息展示及分析图:
- 通过请求 /usrborrowdata 接口获取读者借阅信息数据,并将其显示在表格中。
- 通过请求 /usrborrowanalysisdata 接口获取图书借阅排行数据,并将其用柱状图进行可视化展示。
- 柱状图显示了图书借阅量排名前十的图书信息。
- 前端大同小异,图片生成可以关注一下
- 他这里是用接口返回的数据和import { Pie, Column } from '@ant-design/plots';的组件来生成饼状图和条状图图片
- 2.信息审核
- bookdata、newbookdata、applyborrowdata:分别用于存储所有书籍数据、新书数据和待审核的借阅申请数据。
- axios.get('http://127.0.0.1:5000/bookdata') 获取所有书籍数据。
- axios.get('http://127.0.0.1:5000/newbookdata') 获取新书数据。
- axios.get('http://127.0.0.1:5000/usrborrowlistdata') 获取用户借阅列表数据,并过滤和计算是否超期。
1)如何实现管理员与普通用户的划分的
前端:如果勾选了管理员复选框,则会将一个名为 admin
的键添加到本地存储中,并设置其值为 true
。在后续的组件中,检查本地存储中是否存在 admin
键以确定用户是否为管理员。
2)如何实现未登录就不能访问的呢
他写了很多组件,一个是预览状态的,也就是未登录,另一个home就是登录的,很简单
他这里是直接将react打包后的文件塞到flask的文件夹里的,所以我们这次要启动两个文件夹了,flask run 启动,为接口做准备,这里就不单独启动flask了,cd my-app,npm install,npm start 启动前端
- 收藏显示问题
- const Collect = (props)=>{
- const data = props.data
-
- const [isCollect,setCollect]=useState(false)
- const [messageApi, contextHolder] = message.useMessage();
-
- const info = async() => {
-
- const self = window.localStorage.getItem('loggedUser')
- const newValue = {
- username:self,
- isbn:data.isbn,
- isCollect
- }
- // console.log(newValue)
-
- setCollect(!isCollect)
-
- const res1 = await uniPost('http://127.0.0.1:5000/tocollect',newValue)
-
- // console.log('res1',res1)
- // 这里状态滞后更新,非取反isCollect为真实状态
- messageApi.info(!isCollect ? '收藏成功!' : '取消成功!')
- // console.log(data.isbn)
- // console.log(isCollect)
- };
-
- return (
- <>
- {contextHolder}
- <Button type="primary" onClick={info} ghost>
- {isCollect ? '取消收藏' :'收藏'}
- </Button>
- </>
- );
- }
- 上面的组件 Collect 通过 useState hook 来管理 isCollect 变量的状态。初始状态为 false,表示未收藏。当用户点击按钮时,触发 info 函数,该函数会根据当前的 isCollect 状态来执行不同的操作:
- 如果 isCollect 是 false,表示当前状态是未收藏,那么点击按钮后会执行收藏操作,并将 isCollect 置为 true。
- 如果 isCollect 是 true,表示当前状态是已收藏,那么点击按钮后会执行取消收藏操作,并将 isCollect 置为 false。
- 这很好,无可指摘,我们需要做的是在一开始加载的时候,通过查询,调用接口将已收藏的显示为已收藏
- {
- "author": "\u7d2b\u91d1\u9648",
- "intro": "\u7ed3\u5a5a\u7b2c\u56db\u5e74\uff0c\u5f90\u9759\u6709\u4e86\u5916\u9047\uff0c\u5e76\u5411\u5f20\u4e1c\u5347\u63d0\u51fa\u79bb\u5a5a\u3002\u4f5c\u4e3a\u4e0a\u95e8\u5973\u5a7f\u5",
- "isbn": "9787540468422",
- "key": 11,
- "name": "\u574f\u5c0f\u5b69",
- "number": 8,
- "price": "54.00",
- "pubdate": "2018-7-1",
- "publish": "\u6e56\u5357\u6587\u827a\u51fa\u7248\u793e",
- "type": "\u60ac\u7591"
- },
- 通过接口返回的数据可以看到,里面并没有一个字段来显示书籍是否已经被收藏,那么作者是如何实现收藏功能的,它通过将username,iscollect和isbn传给后端,并且建了一个表(包含id,用户名和对应的isbn)来实现收藏功能和取消收藏功能
- 那我们就可以通过查询这个表的信息来筛选当前用户已收藏或者未收藏的书籍显示在booklist
- 但是这样太麻烦了,所以我觉得给这个bookitem加一个字段来显示是否被收藏,虽然这样同样麻烦,需要我改前端和后端
- 1)进入数据库
- 执行ALTER TABLE `bookitem` ADD COLUMN `iscollect` BOOLEAN DEFAULT 0;(默认为0,没有收藏)
- 2)更改代码
- 首先是models.py,加入最后一行
- # 图书信息表
- class bookitem(db.Model):
- bid=db.Column(db.Integer,primary_key=True,nullable=False,autoincrement=True)
- name=db.Column(db.String(50))
- author=db.Column(db.String(50))
- publish=db.Column(db.String(40))
- isbn=db.Column(db.String(60))
- price=db.Column(db.String(20))
- number=db.Column(db.Integer)
- intro=db.Column(db.String(3000))
- pubdate=db.Column(db.String(30))
- type=db.Column(db.String(30))
- iscollect = db.Column(db.Boolean)
- 然后是views.py,加入最后一行"iscollect": x.iscollect,返回给前端
- # 图书类路由-----------------------------------------
- @app.route('/bookdata', methods=["GET"])
- @cross_origin()
- def bookdata():
- res1 = bookitem.query.all()
- response = []
- for x in res1:
- # print(x)
- item = {"key":x.bid,"name":x.name, "author":x.author, "publish":x.publish, "isbn":x.isbn, "price":x.price, "number":x.number, "type":x.type,"intro":x.intro, "pubdate":x.pubdate, "iscollect": x.iscollect }
- response.append(item)
- return response
- 再然后是前端代码bookdata.js里面的Collect
- const [isCollect,setCollect]=useState(data.iscollect)
- 然后注释掉假的// messageApi.info(!isCollect ? '收藏成功!' : '取消成功!')
- 再修改后端代码
- def tocollect():
- sth = request.json
- # print('json msg:', sth)
- username = sth['username']
- isbn = sth['isbn']
- iscollect = sth['isCollect']
- # 前面为要收藏,后面为已收藏取消收藏
- if not iscollect:
- # 先找书的所有收藏信息
- res1 = bookCollect.query.filter(bookCollect.isbn == isbn).all()
- tag = False
- # 再判断是否收藏这本书
- for x in res1:
- if x.username == username:
- tag=True
- # 没收藏就记录信息,收藏过就忽略
-
- db.session.commit()
- if not tag:
- newitem = bookCollect(username=username,isbn=isbn)
- db.session.add(newitem)
- book = bookitem.query.filter_by(isbn=isbn).first()
- book.iscollect = True # 更新iscollect为True
- db.session.commit()
- return jsonify({"msg": "collect ok!"})
- else:
- book = bookitem.query.filter_by(isbn=isbn).first()
- book.iscollect = False # 更新iscollect为True
- res1 = bookCollect.query.filter(bookCollect.isbn == isbn).all()
- for x in res1:
- if x.username==username:
- db.session.delete(x)
- db.session.commit()
- return jsonify({"msg": "cancel collect ok!"})
- 结束,借阅的部分同理,我就不继续了
- messageBoard.js
- 实现多级评论
- 1)对数据库模型进行修改
- 对messageboardchildcomment数据库加入parentId字段 int
- class messageboardchildcomment(db.Model):
- id=db.Column(db.Integer,primary_key=True,nullable=False,autoincrement=True)
- commentId = db.Column(db.Integer)
- fromId = db.Column(db.String(30))
- content = db.Column(db.String(300))
- createTime = db.Column(db.String(50))
- parentId=db.Column(db.Integer)
- 2)对前端代码修改
- 将之前的子代码抽出来
- const renderComments = (comments,handleChildDelete) => {
- return comments.map((item) => {
- return (
- <div className='childcomment' key={item.id}>
- <span className='childusr'>{item.fromId}</span>
- <span className='childtime'>{timetrans(Number(item.createTime))}</span>
- {isAdmin && <span className='childdelete' onClick={() => { handleChildDelete(item.id) }}>{<DeleteOutlined />}</span>}
- <div className='childcontent'>{item.content}</div>
- {item.child && renderComments(item.child)}
- <div>
- <span className='likeicon' onClick={handlelikeclick}>
- {click ? <LikeFilled /> : <LikeOutlined />}
- </span>
- {state.likeNum}
- <span className='parentreply' onClick={handlereplyclick}>{replyclick ?'取消':'回复'}</span>
- {
- isAdmin&&<span className='parentdelete' onClick={handleparentdelete}>{<DeleteOutlined/>}</span>
- }
-
- </div>
- </div>
- );
- });
- };
- 之前的state.child && state.child.map((item) => .....
- 改为 {state.child && renderComments(state.child)}
- 3)后端代码:
- 有点复杂,数据库设计的有问题我重新写一个demo
- 1)创建数据库的新表
- SET NAMES utf8mb4;
- SET FOREIGN_KEY_CHECKS = 0;
-
- -- ----------------------------
- -- Table structure for comment
- -- ----------------------------
- DROP TABLE IF EXISTS `comment`;
- CREATE TABLE `comment` (
- `id` int NOT NULL AUTO_INCREMENT,
- `author` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
- `text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
- `parent_id` int NULL DEFAULT NULL,
- `level` int NULL DEFAULT 0,
- `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (`id`) USING BTREE,
- INDEX `parent_id`(`parent_id` ASC) USING BTREE,
- CONSTRAINT `comment_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `comment` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
- ) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-
- SET FOREIGN_KEY_CHECKS = 1;
- 2)models.py的更新
- class Comment(db.Model):
- id = db.Column(db.Integer, primary_key=True)
- author = db.Column(db.String(255), nullable=False)
- text = db.Column(db.Text, nullable=False)
- parent_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=True)
- level = db.Column(db.Integer, default=0)
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
- replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id]), lazy=True)
- 3)后端代码的更新
-
- # 获取所有评论
- @app.route('/api/comments', methods=['GET'])
- @cross_origin()
- def get_comments():
- comments = Comment.query.order_by(Comment.created_at.asc()).all()
-
- def build_tree(comments, parent_id=None):
- tree = []
- for comment in comments:
- if comment.parent_id == parent_id:
- replies = build_tree(comments, comment.id)
- comment_data = {
- 'id': comment.id,
- 'author': comment.author,
- 'text': comment.text,
- 'parent_id': comment.parent_id,
- 'level': comment.level,
- 'created_at': comment.created_at.isoformat(),
- 'replies': replies
- }
- tree.append(comment_data)
- return tree
-
- return jsonify(build_tree(comments))
-
- # 添加新评论
- @app.route('/api/comments', methods=['POST'])
- @cross_origin()
- def add_comment():
- data = request.json
- new_comment = Comment(
- author=data['author'],
- text=data['text'],
- parent_id=data.get('parent_id'),
- level=data.get('level', 0)
- )
- if new_comment.parent_id:
- parent_comment = Comment.query.get(new_comment.parent_id)
- if parent_comment:
- new_comment.level = parent_comment.level + 1
-
- db.session.add(new_comment)
- db.session.commit()
-
- return jsonify({
- 'id': new_comment.id,
- 'author': new_comment.author,
- 'text': new_comment.text,
- 'parent_id': new_comment.parent_id,
- 'level': new_comment.level,
- 'created_at': new_comment.created_at.isoformat(),
- 'replies': []
- }), 201
-
- # 更新评论
- @app.route('/api/comments/
' , methods=['PUT']) - @cross_origin()
- def update_comment(id):
- data = request.json
- comment = Comment.query.get_or_404(id)
- comment.text = data['text']
- db.session.commit()
- return jsonify({
- 'id': comment.id,
- 'author': comment.author,
- 'text': comment.text,
- 'parent_id': comment.parent_id,
- 'level': comment.level,
- 'created_at': comment.created_at.isoformat()
- })
- def delete_comment_and_replies(comment_id):
- comment = Comment.query.get_or_404(comment_id)
- for reply in comment.replies:
- delete_comment_and_replies(reply.id)
- db.session.delete(comment)
- # 删除评论
- @app.route('/api/comments/
' , methods=['DELETE']) - @cross_origin()
- def delete_comment(id):
- delete_comment_and_replies(id)
- db.session.commit()
- return jsonify({'message': 'Comment deleted'}), 204
- 4)前端代码的更新
- import React, { useState, useEffect } from 'react';
- import axios from 'axios';
-
- // Comment Component
- const Comment = ({ comment, onReply }) => {
- const [showReplyForm, setShowReplyForm] = useState(false);
- const [replyAuthor, setReplyAuthor] = useState('');
- const [replyText, setReplyText] = useState('');
-
- const handleReply = () => {
- onReply(comment.id, replyAuthor, replyText);
- setReplyAuthor('');
- setReplyText('');
- setShowReplyForm(false);
- };
-
- return (
- <div style={{ marginLeft: comment.level * 20, borderLeft: '1px solid #ccc', paddingLeft: '10px' }}>
- <p><strong>{comment.author}</strong>: {comment.text}</p>
- <button onClick={() => setShowReplyForm(!showReplyForm)}>Reply</button>
- {showReplyForm && (
- <div>
- <input
- type="text"
- placeholder="Your name"
- value={replyAuthor}
- onChange={(e) => setReplyAuthor(e.target.value)}
- />
- <input
- type="text"
- placeholder="Your reply"
- value={replyText}
- onChange={(e) => setReplyText(e.target.value)}
- />
- <button onClick={handleReply}>Add Reply</button>
- </div>
- )}
- </div>
- );
- };
-
- // CommentThread Component
- const CommentThread = ({ comment, onReply }) => {
- return (
- <div>
- <Comment comment={comment} onReply={onReply} />
- {comment.replies && comment.replies.map(reply => (
- <CommentThread key={reply.id} comment={reply} onReply={onReply} />
- ))}
- </div>
- );
- };
-
- // CommentBox Component
- const CommentBox = () => {
- const [comments, setComments] = useState([]);
- const [author, setAuthor] = useState('');
- const [text, setText] = useState('');
-
- useEffect(() => {
- axios.get('http://127.0.0.1:5000/api/comments')
- .then(response => {
- setComments(response.data);
- })
- .catch(error => {
- console.error('There was an error fetching the comments!', error);
- });
- }, []);
-
- const addComment = () => {
- const newComment = {
- author,
- text,
- parent_id: null,
- level: 0,
- };
- axios.post('http://127.0.0.1:5000/api/comments', newComment)
- .then(response => {
- setComments([...comments, response.data]);
- setAuthor('');
- setText('');
- })
- .catch(error => {
- console.error('There was an error adding the comment!', error);
- });
- };
-
- const handleReply = (commentId, replyAuthor, replyText) => {
- const newReply = {
- author: replyAuthor,
- text: replyText,
- parent_id: commentId,
- level: 0,
- };
- axios.post('http://127.0.0.1:5000/api/comments', newReply)
- .then(response => {
- const updatedComments = [...comments];
- const addReplyToComment = (comments, commentId, reply) => {
- for (let comment of comments) {
- if (comment.id === commentId) {
- reply.level = comment.level + 1;
- comment.replies.push(reply);
- return true;
- }
- if (comment.replies.length > 0) {
- const found = addReplyToComment(comment.replies, commentId, reply);
- if (found) return true;
- }
- }
- return false;
- };
-
- addReplyToComment(updatedComments, commentId, response.data);
- setComments(updatedComments);
- })
- .catch(error => {
- console.error('There was an error adding the reply!', error);
- });
- };
-
- return (
- <div>
- <div>
- <input
- type="text"
- placeholder="Your name"
- value={author}
- onChange={(e) => setAuthor(e.target.value)}
- />
- <input
- type="text"
- placeholder="Your comment"
- value={text}
- onChange={(e) => setText(e.target.value)}
- />
- <button onClick={addComment}>Add Comment</button>
- </div>
- <div>
- {comments.map(comment => (
- <CommentThread key={comment.id} comment={comment} onReply={handleReply} />
- ))}
- </div>
- </div>
- );
- };
-
-
-
- export { CommentBox }