摘要:本项目中采用了进行状态管理,的主要作用是允许状态在不同分支的组件中进行传递,从而避免了使用原始方法如导致的不同分支组件之间数据无法传递子组件无法修改父组件状态等问题。
项目功能
最近在做一个旧书交易网站,本属于B/S体系结构的课程作业,但由于采用了新的框架所以跃跃欲试想都记录下来。
技术选型 数据库实现一个旧书交易网站,基本功能如下:
实现用户注册、登录功能,用户注册时需要填写必要的信息并验证,如用户名、密码要求在6字节以上,email的格式验证,并保证用户名和email在系统中唯一。
用户登录后可以发布要交易的书籍,需要编辑相关信息,包括书名、原价、出售价、类别和内容介绍等信息、外观照片等,可以通过ISBN和书名链接到外部系统(如Amazon/京东/当当等网站)的详细介绍页面。
根据用户发布的书籍聚合生成首页,可以分类检索。
用户可以设置交易模式为寄送还是线下交易,生成订单时录入不同内容。
集成一个消息系统,买家和卖家之间可以通信。
提供求购模块,用户可以发布自己想要的书籍。
界面样式需要适配PC和手机的浏览器。
实现一个Android或iphone客户端软件,功能同网站,额外支持定位功能,发布时记录位置,可以根据用户的位置匹配最近的待售书籍。消息和订单支持推送。
数据库使用MySQL进行开发,因为环境之前都已经配好了( ̄▽ ̄)"
后端经过Express和Koa比对,最终选择Koa作为基于Node.js的Web开发框架。Koa是一个新的web框架,由Express幕后原班人马打造,语法上也使用了ES6新的语法(例如丢弃了回调函数而使用async解决异步调用问题),看起来十分优雅o( ̄▽ ̄)o
前端采用React+Semantic UI,由于之前对React有足够多的实践,因此本次重点还是放在后端开发及前后端连接上……
开发过程 参考教程Vue+Koa全栈开发
Koa框架教程 - 阮一峰
Koa框架搭建 初始化
命令行输入
npm init -y npm i koa koa-json npm i -D nodemon
更改package.json内容,将scripts中的内容更改为"start":"nodemon app.js"
根目录下新建app.js
const Koa = require("koa"); const json = require("koa-json"); const logger = require("koa-logger"); const KoaRouter = require("koa-router"); const parser = require("koa-bodyparser"); const app = new Koa(); const router = new KoaRouter(); // Json Prettier Middleware app.use(json()); app.use(parser()); app.use(logger()); // Simple Middleware Example // app.use(async ctx => (ctx.body = { msg: "Hello world" })); app.listen(4113, () => console.log("----------Server Started----------")); module.exports = app;
命令行输入node app.js,浏览器打开localhost:3000查看返回数据
sequelize连接数据库
安装包
npm install sequelize-auto -g npm install tedious -g npm install mysql -g
进入src目录,输入sequelize-auto -o "./schema" -d bookiezilla -h 127.0.0.1 -u root -p 3306 -x XXXXX -e mysql,(其中 -o 参数后面的是输出的文件夹目录, -d 参数后面的是数据库名, -h 参数后面是数据库地址, -u 参数后面是数据库用户名, -p 参数后面是端口号, -x 参数后面是数据库密码 -e 参数后面指定数据库为mysql)
此时schema文件夹下会自动生成三个表的文件,例如:
/* jshint indent: 2 */ module.exports = function(sequelize, DataTypes) { return sequelize.define( "book", { BookID: { type: DataTypes.INTEGER(11), allowNull: false, primaryKey: true }, BookName: { type: DataTypes.STRING(45), allowNull: true }, BookCostPrice: { type: "DOUBLE", allowNull: true }, BookSalePrice: { type: "DOUBLE", allowNull: true }, BookCategory: { type: DataTypes.STRING(45), allowNull: true }, BookPhoto: { type: DataTypes.STRING(45), allowNull: true }, BookContent: { type: DataTypes.STRING(45), allowNull: true }, BookISBN: { type: DataTypes.STRING(45), allowNull: true } }, { tableName: "book" } ); };
在serversrcconfig下新建文件database.js,用于初始化Sequelize和数据库的连接。
const Sequelize = require("sequelize"); // 使用url连接的形式进行连接,注意将root: 后面的XXXX改成自己数据库的密码 const BookieZilla = new Sequelize( "mysql://root:XXXXX@localhost/bookiezilla", { define: { timestamps: false// 取消Sequelzie自动给数据表加入时间戳(createdAt以及updatedAt),否则进行增删改查操作时可能会报错 } } ); module.exports = { BookieZilla // 将BookieZilla暴露出接口方便Model调用 };
为方便之后根据用户id查询信息,可先在数据库中随意增加一条数据。
在serversrcmodels下新建文件userModel.js,数据库和表结构文件连接起来。
const db = require("../config/database.js"); const userModel = "../schema/user.js";// 引入user的表结构 const BookieZilla = db.BookieZilla;// 引入数据库 const User = BookieZilla.import(userModel);// 用sequelize的import方法引入表结构,实例化了User。 const getUserById = async function(id) { const userInfo = await User.findOne({ where: { UserID: id } }); return userInfo; }; module.exports = { getUserById, getUserByEmail };
在serversrccontrollers下新建文件userController.js,来执行这个方法,并返回结果。
Koa 提供一个 Context 对象,表示一次对话的上下文(包括 HTTP 请求和 HTTP 回复)。通过加工这个对象,就可以控制返回给用户的内容。
const user = require("../models/userModel.js"); const getUserInfo = async function(ctx) { const id = ctx.params.id;// 获取url里传过来的参数里的id const result = await user.getUserById(id); ctx.body = result;// 将请求的结果放到response的body里返回 }; module.exports = { getUserInfo, vertifyUserLogin };
在serversrc outes下新建文件auth.js,用于规划auth下的路由规则。
const auth = require("../controllers/userController.js"); const router = require("koa-router")(); router.get("/user/:id", auth.getUserInfo); module.exports = router;
回到根目录下的app.js,将这个路由规则“挂载”到Koa上去。
const Koa = require("koa"); const json = require("koa-json"); const logger = require("koa-logger"); const KoaRouter = require("koa-router"); const parser = require("koa-bodyparser"); const auth = require("./src/routes/auth.js");// 引入auth const app = new Koa(); const router = new KoaRouter(); // Json Prettier Middleware app.use(json()); app.use(parser()); app.use(logger()); // Simple Middleware Example // app.use(async ctx => (ctx.body = { msg: "Hello world" })); // Router Middleware router.use("/auth", auth.routes());// 挂载到koa-router上,同时会让所有的auth的请求路径前面加上"/auth"的请求路径。 app.use(router.routes()).use(router.allowedMethods());// 将路由规则挂载到Koa上。 app.listen(4113, () => console.log("----------Server Started----------")); module.exports = app;
API Test
SUCCESS!!!
前后端数据传递由于本项目采用的是前后端分离的架构,因此需要通过json来传递数据,以实现登录功能为例来阐述实现的具体步骤。
后端验证登录
serversrcmodelsuserModel.js增加方法,用于通过邮箱查找用户。
// ... const getUserByEmail = async function(email) { const userInfo = await User.findOne({ where: { UserEmail: email } }); return userInfo; }; module.exports = { getUserById, getUserByEmail };
serversrccontrolleruserController.js增加方法,用于验证登录信息并将结果以json形式返回给前端。
注意此处实际上应用了JSON-WEB-TOKEN实现无状态请求,关于jwt的原理和实现方法请参考这篇文章和这篇文章。
简单来说,运用了JSON-WEB-TOKEN的登录系统应该是这样的:
用户在登录页输入账号密码,将账号密码(密码进行md5加密)发送请求给后端
后端验证一下用户的账号和密码的信息,如果符合,就下发一个TOKEN返回给客户端。如果不符合就不发送TOKEN回去,返回验证错误信息。
如果登录成功,客户端将TOKEN用某种方式存下来(SessionStorage、LocalStorage),之后要请求其他资源的时候,在请求头(Header)里带上这个TOKEN进行请求。
后端收到请求信息,先验证一下TOKEN是否有效,有效则下发请求的资源,无效则返回验证错误。
使用前需要安装相应库:
npm i koa-jwt jsonwebtoken util -s
此外,为保证安全性,后端数据库的密码不能采用明文保存,此处使用bcrypt的加密方式。
npm i bcryptjs -s
const user = require("../models/userModel.js"); const jwt = require("jsonwebtoken"); const bcrypt = require("bcryptjs"); const getUserInfo = async function(ctx) { const id = ctx.params.id; const result = await user.getUserById(id); ctx.body = result; }; const vertifyUserLogin = async function(ctx) { const data = ctx.request.body; // post过来的数据存在request.body里 const userInfo = await user.getUserByEmail(data.email); if (userInfo != null) { // 如果查无此用户会返回null if (!bcrypt.compareSync(data.psw, userInfo.UserPsw) { ctx.body = { status: false, msg: "Wrong password" }; } else { // 如果密码正确 const userToken = { id: userInfo.UserID, email: userInfo.UserEmail }; const secret = "react-koa-bookiezilla"; // 指定密钥,这是之后用来判断token合法性的标志 const token = jwt.sign(userToken, secret); // 签发token ctx.body = { status: true, token: token // 返回token }; } } else { ctx.body = { status: false, msg: "User doesn"t exist" }; } }; module.exports = { getUserInfo, vertifyUserLogin };
更新serversrc outesauth.js中的路由规则。
const auth = require("../controllers/userController.js"); const router = require("koa-router")(); router.get("/user/:id", auth.getUserInfo); router.post("/login", auth.vertifyUserLogin); module.exports = router;前端校验数据并发送请求
前端主要使用了react-router进行路由跳转,使用semantic-ui作为UI组件库,使用axios发送请求,Login.js代码如下:
import React, { Component } from "react"; import { Button, Form, Grid, Header, Image, Message, Segment, Loader } from "semantic-ui-react"; import { NavLink, withRouter } from "react-router-dom"; import axios from "axios"; import Logo from "../images/logo.png"; class Login extends Component { state = { email: "", psw: "", alert: false, load: false }; vertifyFormat = () => { var pattern = /^([A-Za-z0-9_-.])+@([A-Za-z0-9_-.])+.([A-Za-z]{2,4})$/; return pattern.test(this.state.email) && this.state.psw.length >= 6; }; sendLoginRequest = () => { if (this.vertifyFormat()) { this.setState({ alert: false, load: true }); axios .post("/auth/login", { email: this.state.email, psw: this.state.psw }) .then(res => { console.log(res); }) .catch(err => { console.log(err); }); } else { this.setState({ alert: true }); } }; render() { var alert = this.state.alert === false ? ( ) : (React配置代理); var load = this.state.load === false ? : ; return ( ); } } export default withRouter(Login); Log-in to your B::kzilla New to us? Sign Up
安装http-proxy-middleware中间件。
npm install http-proxy-middleware -s
create-react-app初始化的项目需要eject,使基本配置暴露出来。
npm run eject
clientsrc下新建文件setupProxy.js,配置代理转发信息。
const proxy = require("http-proxy-middleware"); module.exports = function(app) { app.use( proxy("/api", { target: "http://localhost:4113", changeOrigin: true }) ); app.use( proxy("/auth", { target: "http://localhost:4113", changeOrigin: true }) ); };
clientscriptsstart.js中进行配置,在const devServer = new WebpackDevServer(compiler, serverConfig);后添加语句require("../src/setupProxy")(devServer);
发送请求格式如下:
axios .post("/auth/login", { email: this.state.email, psw: this.state.psw }) .then(res => { console.log(res); }) .catch(err => { console.log(err); });
喜闻乐见的测试环节!
设计原理 数据库 User*UserID | UserName | UserPsw | *UserEmail |
---|---|---|---|
INT | VARCHAR(45) | VARCHAR(45) | VARCHAR(45) |
CREATE TABLE `bookiezilla`.`user` ( `UserID` INT NOT NULL, `UserName` VARCHAR(45) NULL, `UserPsw` VARCHAR(45) NULL, `UserEmail` VARCHAR(45) NOT NULL, PRIMARY KEY (`UserID`, `UserEmail`));Book
*BookID | BookName | BookCostPrice | BookSalePrice | BookCategory | BookPhoto | BookContent | BookISBN | BookRefs |
---|---|---|---|---|---|---|---|---|
INT | VARCHAR(45) | DOUBLE | DOUBLE | VARCHAR(45) | VARCHAR(45) | VARCHAR(45) | VARCHAR(45) | VARCHAR(45) |
CREATE TABLE `bookiezilla`.`book` ( `BookID` INT NOT NULL, `BookName` VARCHAR(45) NULL, `BookCostPrice` DOUBLE NULL, `BookSalePrice` DOUBLE NULL, `BookCategory` VARCHAR(45) NULL, `BookPhoto` VARCHAR(45) NULL, `BookContent` VARCHAR(45) NULL, `BookISBN` VARCHAR(45) NULL, PRIMARY KEY (`BookID`));Order
*OrderID | *UserID | *BookID | TradeMethod | TradeStatus | TradeParty | TraderID |
---|---|---|---|---|---|---|
INT | INT | INT | VARCHAR(45) | VARCHAR(45) | VARCHAR(45) | INT |
CREATE TABLE `bookiezilla`.`order` ( `OrderID` INT NOT NULL, `UserID` INT NOT NULL, `BookID` INT NOT NULL, `TradeMethod` VARCHAR(45) NULL, `TradeStatus` VARCHAR(45) NULL, `TraderID` INT NULL, PRIMARY KEY (`OrderID`));前端 目录结构
. │ .gitignore │ package-lock.json │ package.json │ README.md │ yarn.lock │ ├─config // 基本配置文件 │ │ env.js │ │ modules.js │ │ paths.js │ │ pnpTs.js │ │ webpack.config.js │ │ webpackDevServer.config.js │ │ │ └─jest │ cssTransform.js │ fileTransform.js │ ├─public │ favicon.ico │ index.html │ manifest.json │ ├─scripts // eject后生成的文件配置 │ build.js │ start.js │ test.js │ └─src // 主要页面及组件部分 │ App.css │ App.js │ index.css │ index.js │ serviceWorker.js │ setupProxy.js // 设置代理转发,解决跨域问题 │ ├─actions // react-redux需要定义的actions │ UpdateActions.js │ ├─components // 页面的组件部分 │ BookList.jsx │ BookMarket.jsx │ FeedBack.jsx │ OrderInfo.jsx │ PublishForm.jsx │ SearchBar.jsx │ SideMenu.jsx │ StatisticData.jsx │ StepFlow.jsx │ ├─images // 项目中使用的图片资源 │ logo.png │ matthew.png │ ├─pages // 页面部分 │ Home.jsx │ Login.jsx │ Market.jsx │ Message.jsx │ Publish.jsx │ Signup.jsx │ └─reducers // react-redux需要定义的reducers rootReducer.js实现细节
项目中使用了react-router来控制路由,基本原理如下:
在App.js中引入路由对应的页面或组件,并引入react-router-dom中的BrowserRouter、Route、Switch组件进行定义。
// App.jsx import React, { Component } from "react"; import { BrowserRouter, Route, Switch } from "react-router-dom"; import SideMenu from "./components/SideMenu"; import Login from "./pages/Login"; import Signup from "./pages/Signup"; import Home from "./pages/Home"; import Market from "./pages/Market"; import Publish from "./pages/Publish"; import Message from "./pages/Message"; import OrderInfo from "./components/OrderInfo"; class App extends Component { render() { return (); } } export default App; {/* Only match one */}
当项目页面中需要进行页面跳转时,可使用react-router-dom中的withRouter将组件包裹起来,再使用NavLink进行跳转。
// Login.jsx import { NavLink, withRouter } from "react-router-dom"; class Login extends Component { ..... sendLoginRequest = () => { ...... this.props.history.push("/home"); render(){ ...... } }; export default withRouter(Login);
本项目中采用了react-redux进行状态管理,redux的主要作用是允许状态在不同分支的组件中进行传递,从而避免了使用原始方法(如this.props)导致的不同分支组件之间数据无法传递、子组件无法修改父组件状态等问题。具体使用方法如下:
在src educers下新建文件rootReducer.js用于更新中心状态树中的信息。
// rootReducer.js const initState = { id: null, token: null }; const rootReducer = (state = initState, action) => { if (action.type === "UPDATE_ID") { return { ...state, id: action.id }; } if (action.type === "UPDATE_TOKEN") { return { ...state, token: action.token }; } return state; }; export default rootReducer;
在srcactions中新建文件UpdateActions.js用于定义行为。
// UpdateActions.js export const updateId = id => { return { type: "UPDATE_ID", id: id }; }; export const updateToken = token => { return { type: "UPDATE_TOKEN", token: token }; };
在srcindex.js中使用react-redux中的组件对项目入口文件进行包裹,并在全局范围内建立状态树。
// index.js import React from "react"; import ReactDOM from "react-dom"; import "./index.css"; import App from "./App"; import * as serviceWorker from "./serviceWorker"; import "semantic-ui-css/semantic.min.css"; import { createStore } from "redux"; import { Provider } from "react-redux"; import rootReducer from "./reducers/rootReducer"; const store = createStore(rootReducer); ReactDOM.render(, document.getElementById("root") ); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister(); ,
当需要更新状态树中的信息时,使用引入的action作为函数进行更新。
// Login.jsx import { connect } from "react-redux"; import { updateId, updateToken } from "../actions/UpdateActions"; class Login extends Component { ...... sendLoginRequest = () => { ...... this.props.updateId(res.data.id); this.props.updateToken(res.data.token); ...... }; } const mapStateToProps = state => { return {}; }; const mapDispatchToProps = dispatch => { return { updateToken: token => { dispatch(updateToken(token)); }, updateId: id => { dispatch(updateId(id)); } }; }; export default connect( mapStateToProps, mapDispatchToProps )(withRouter(Login));
当需要使用状态树中的信息时,先调用react-redux中的connect包裹组件,再使用this.props直接调用即可。
// PublishForm.jsx import { connect } from "react-redux"; class PublishForm extends Component { ...... var UserID = this.props.id; var UserToken = this.props.token; ...... } const mapStateToProps = state => { return { id: state.id, token: state.token }; }; export default connect(mapStateToProps)(PublishForm);后端 目录结构
. │ app.js │ package-lock.json │ package.json │ └─src ├─config // 数据库配置 │ database.js │ ├─controllers // 控制器,获取请求数据并调用models中的方法进行处理并返回结果 │ apiController.js │ msgController.js │ userController.js │ ├─models // 实例模型,主要使用Sequelize定义的方法对数据库进行增删改查 │ bookModel.js │ CommentModel.js │ orderModel.js │ userModel.js │ ├─routes // 路由,不同文件对应不同类型的api接口,分别与授权、功能实现、信息传递有关 │ api.js │ auth.js │ msg.js │ └─schema // 数据库表结构,可使用Sequelize自动生成 book.js comment.js order.js user.js实现细节
当Koa后端监听的端口接收到请求时,会根据app.js中的路由规则进行处理,我们将不同类型的接口定义在不同文件中,再通过router.use()进行调用,避免发生接口冗乱复杂的情况。
// app.js const Koa = require("koa"); const json = require("koa-json"); const logger = require("koa-logger"); const KoaRouter = require("koa-router"); const parser = require("koa-bodyparser"); const auth = require("./src/routes/auth.js"); const api = require("./src/routes/api.js"); const msg = require("./src/routes/msg.js"); const app = new Koa(); const router = new KoaRouter(); // Json Prettier Middleware app.use(json()); app.use(parser()); app.use(logger()); // Simple Middleware Example // app.use(async ctx => (ctx.body = { msg: "Hello world" })); // Router Middleware router.use("/auth", auth.routes()); router.use("/msg", msg.routes()); router.use("/api", api.routes()); app.use(router.routes()).use(router.allowedMethods()); app.listen(4113, () => console.log("----------Server Started----------")); module.exports = app;
// auth.js const auth = require("../controllers/userController.js"); const router = require("koa-router")(); router.get("/user/:id", auth.getUserInfo); router.post("/login", auth.vertifyUserLogin); router.post("/signup", auth.signupNewUser); module.exports = router;
// api.js const api = require("../controllers/apiController.js"); const router = require("koa-router")(); router.get("/getbooks", api.getAllBooks); router.get("/getorder/:id", api.getOrderInfo); router.post("/searchbooks", api.searchBooks); router.post("/publish", api.publishNewBook); router.post("/confirmorder", api.updateOrderOfTrade); module.exports = router;
// msg.js const msg = require("../controllers/msgController.js"); const router = require("koa-router")(); router.get("/getcomments", msg.getAllComments); router.post("/newcomment", msg.publishNewComment); module.exports = router;项目成果 登录注册
Bookizilla能够实现用户注册、用户登录功能,其中对用户注册时需要的数据做了格式处理(如验证Email格式、保证两次密码输入数据相符且不小于6字节等)。如果用户在注册过程中出现错误,则会出现相应提示以指导用户进行正确输入。
Login.jsx
Signup.jsx
个人主页Bookiezilla的主页呈现的是与该用户有关的信息数据(如FAVES、VIEWS等,但由于目前后端并未储存相关数据所以暂用了mocks)及该用户所发布的所有书籍。
Home.jsx
书籍市场Bookiezilla的书籍市场呈现了所有用户发布的所有书籍,用户可以使用上方的搜索框输入关键词(如书名、标签 、ISBN等)。用户还可点击图书下方按钮以查看具体信息,进而决定是否达成交易,也可点击链接在Amazon中查看书籍的详细介绍。
Market.jsx
书籍发布Bookiezilla允许用户发布书籍,并设置订单的关键信息(如书籍基本信息、交易模式、寻求买家或卖家等)。需要注意的是,由于书籍发布和书籍求购很大一部分内容是重合的,所以此处将二者合并并且给出TradeParty选项来使用户选择是想要发布书籍还是求购书籍。
Publish.jsx
信息发布Bookiezilla设置了信息发布面板,用于用户之间的沟通交流、信息发布等。用户可直接发布评论或回复他人的评论,从而进行持续性的交流。
Message.jsx
https://github.com/Sylvie-Hsu...
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/106745.html
Github上的脚手架实在太多,可能大多数都是只专注在前端的web开发,例如流行的React生态中的create-react-app和Vue生态中的Vue-cli, 但是可能作为像我一样的全栈开发,一个只关注在前端开发的脚手架满足不了所有的需求,我们可能需要开发更复杂的全栈JS的项目,所以这里介绍又一个基于NodeJS的全栈开发框架 koa-web-kit,不一定适合所有人,但至少又多了个选择?。...
稍微整理了一下自己平时看到的前端学习资源,分享给大家。 html MDN:Mozilla开发者网络 SEO:前端开发中的SEO css 张鑫旭:张鑫旭的博客 css精灵图:css精灵图实践 栅格系统:详解CSS中的栅格系统 媒体查询:css媒体查询用法 rem布局:手机端页面自适应布局 移动前端开发之viewport的深入理解:深入理解viewport 淘宝前端布局:手机淘宝移动端布局 fl...
摘要:是一个基于和的服务器端和浏览器端的的前后端全栈应用框架。是的组件,并且会进行数据初始化不但可以支持的数据初始化,还可以合并和的,使用同一个,和的无缝结合。 koa-cola是一个基于koa和react的服务器端SSR(server side render)和浏览器端的SPA(single page application)的web前后端全栈应用框架。 koa-cola使用typescr...
摘要:序列文章从项目中由浅入深的学习微信小程序和快应用从项目中由浅入深的学习从项目中由浅入深的学习前言的出现前端已经可以用一把梭从前端写到后台。 showImg(https://segmentfault.com/img/bVbrRI5?w=1920&h=1080); 序列文章 从项目中由浅入深的学习vue,微信小程序和快应用 (1)从项目中由浅入深的学习react (2)从项目中由浅入深的学...
摘要:搭建的博客曾经用的写的博客,现在看来已经很了,所以用目前最火的框架重构一下。后端重构博客嘛,以前用写的后台,所以略懂一些,作为一个前端开发,目标就是全栈嘛,选用了最为流行的也用了目前最为流行的作为后端配合。 React-Node搭建的博客 曾经用的php+mysql+js写的博客,现在看来已经很low了,所以用目前最火的react+koa框架重构一下。先上地址吧:目前线上版本http:...
阅读 2772·2021-10-14 09:42
阅读 826·2021-10-11 10:57
阅读 773·2019-08-30 15:54
阅读 1913·2019-08-30 13:50
阅读 1684·2019-08-30 11:19
阅读 932·2019-08-29 12:38
阅读 1424·2019-08-26 11:51
阅读 1388·2019-08-26 10:48