Loading

结对作业二

这个作业属于哪个课程 2021春软件工程实践S班
这个作业要求在哪里 结对第二次作业
结对学号 221801215&221801231
这个作业的目标 实现结对作业一原型中的部分功能
其他参考文献 CSDN,博客园,osChina,segment

1git仓库链接和代码规范链接

git仓库链接:PairProject

代码规范链接:codestyle.md

2PSP表格

221801215

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 180 220
• Estimate • 估计这个任务需要多少时间 180 220
Development 开发 6160 6380
• Analysis • 需求分析 (包括学习新技术) 1800 2000
• Design Spec • 生成设计文档 60 40
• Design Review • 设计复审 60 30
• Coding Standard • 代码规范 (为目前的开发制定合适的规范) 20 30
• Design • 具体设计 200 180
• Coding • 具体编码 3600 3800
• Code Review • 代码复审 120 100
• Test • 测试(自我测试,修改代码,提交修改) 300 200
Reporting 报告 220 265
• Test Report • 测试报告 100 60
• Size Measurement • 计算工作量 20 25
• Postmortem & Process Improvement Plan • 事后总结, 并提出过程改进计划 100 180
合计 6560 6865

221801231

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 180 220
• Estimate • 估计这个任务需要多少时间 180 220
Development 开发 5600 5850
• Analysis • 需求分析 (包括学习新技术) 1300 1500
• Design Spec • 生成设计文档 40 40
• Design Review • 设计复审 20 30
• Coding Standard • 代码规范 (为目前的开发制定合适的规范) 20 20
• Design • 具体设计 200 180
• Coding • 具体编码 3600 3800
• Code Review • 代码复审 120 80
• Test • 测试(自我测试,修改代码,提交修改) 300 200
Reporting 报告 130 135
• Test Report • 测试报告 40 30
• Size Measurement • 计算工作量 20 25
• Postmortem & Process Improvement Plan • 事后总结, 并提出过程改进计划 70 80
合计 5910 6205

3成品展示

3.1访问链接

http://120.77.40.111:10001/

3.2项目展示

用户注册

用户注册界面的表单都实现了严格的文本规则筛选,符合要求即可注册

图片

用户登录

用户登录时先判断表单数据是否存在,再数据库中是否有用户信息,如果数据匹配则实现跳转。

图片

主界面

主界面用户

图片题目添加,显示论文列表,论文列表可删除。

爬取结果侧边栏操作

侧边栏首先在用户列表中搜索题目,如果用户列表中不存在,用户可选择是否继续在数据库中全局搜索。同时在列表末尾进行题目的二次添加即可搜索更多内容。4图片

图片

图片

爬取结果详情显示

爬取结果操作内容详情显示结点击爬取结果卡片即可显示论文的详情内容

图片

关键词图谱显示

显示爬取结果中的热词,在点击热词时会显示热词的相关论文

图片

热度走势显示

通过不同会议和不同年份和不同热词对热度走势进行不同渲染。

图片

登出

用户点击登出按钮即可退出系统返回登录页面。

图片

对讨论过程描述

4.1讨论过程

因为结对作业一对题目的需求都分析过了一遍而且做了原型,这一次结对作业对功能上的理解会清晰很多,讨论的内容主要是工作的划分,网页的模块划分和具体交互逻辑,两人如何协作,项目的代码管理,以及时间上的计划。

4.1.1工作的划分

在分工上,因为一个同学有过做Web的经验,对JavaScript比较熟悉,另一个同学这学期正好选了JavaEE这门课,所以就很刚好的凑成了一个前端一个后端。虽然对前后端的技术的有过一些了解,但是我们根本不会使用前后端的框架,因此整个项目过程中,学习的时间会占很大一部分。因为时间比较短,前端选择了相对好上手的Vue框架,后端选择了应用起来比较简单的SpringBoot+Jpa的框架结构。任务艰巨,但是我们都认为在实践中学会一些技术是值得的。

4.1.2网页的模块划分和具体交互逻辑

网页划分和交互逻辑方面的讨论有了之前做的原型作为参照,再对照结对作业一和结对作业二的要求,把自己想象为使用这个系统的用户,会去怎么操作,如何使用。其实这一部分在原型制作的时候就分析很大一部分了,再讨论一次是为了满足题目的要求,明确要做哪些页面,对哪些操作进行实现,以及为了实现这些,前后端要做哪些工作。

4.1.3协作

协作方面,因为采用的是Vue+SpringBoot前后端框架,前后端分离相对来说比较好实现。前后端之间使用JSON通信。前后端分离主要是要事先定义好好接口,前后端根据接口的实现和使用来实现功能。所需要的接口主要在交互中产生。之前的讨论提示了程序中有哪些接口需要制定,我们的讨论主要围绕接口数据的格式的定义。

4.1.4项目的代码管理

项目代码按照要求使用GitHub托管,一个同学fork作业仓库,将另一位同学添加作为协作者。因为前后端分离,代码之间几乎没有冲突,我们讨论之后,决定就只建了一个dev分支,在该分支下进行开发。

4.1.5时间规划

因为我们两个人都没有用过Vue/SpringBoot框架,而且没有很多的项目经历,也没有在云服务器上部署过(就是菜),在有限的时间里面我们一定要做好规划。在写代码之外,还需要学习新技术,测试接口,部署。经过讨论,先各自学习两天,再继续推进技术上的进一步讨论,对接口的测试要在开发的同时进行,避免前期错误对后续的影响,并且至少留出一天时间把项目部署到服务器上(爬虫功能直接放弃)。

4.2结对讨论截图

图片

图片

图片

5设计实现过程

5.1设计实现过程描述

首先我们先实根据原型先进行了前后端之间主要的接口确认,将主要的功能大致分为五个部分:论文的获取,论文的查询,关键词,热词的获取以及热度走势数据的获取还有登录注册,首先主页部分我们希望用户可以直接看到其论文列表,并且可以对论文进行删除操作,提升用户的直观使用体验。在爬取结果显示页,为了避免用户每次搜索并且增加题目还有再次跳转回主页,我们打算增加功能集合的侧边栏,侧边栏部分我们设计成了VUE的小组件,侧边栏满足用户列表搜索,全局数据库搜索,以及添加新的题目。在数据的显示主页,我们满足了其中的三个部分,论文的结果显示,关键词的获取,还有热度走势的显示。论文的结果显示部分我们希望可以支持查看详情和删除论文,关键词的获取我们设计了通过点击热词,显示热词的相关论文,在热度走势部分,我们设计了使用echarts根据不同的会议显示不同的曲线,同时可以进行年份筛选,在用户所有操作执行完后,我们可以通过清空浏览器session的形式选择登出。在界面美化方面我们打算使用与VUE相配套且更好上手的element ui组件来增强我们用户的直观视觉体验。

.2功能结构图

图片

6代码说明

6.1后端

后端部分主要是对数据库进行增删改查操作,以及响应前端的请求,并向前端发送操作结果。后端使用的是SpringBoot+Jpa的框架。

Entity层,对应数据库表:

@Table(name = "keywords")
@Entity
public class Keyword {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Integer id;
@Column(name = "paperid")
private Integer paperId;
@Column(name = "keyword")
private String keyword;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getPaperId() {
return paperId;
}
public void setPaperId(Integer paperId) {
this.paperId = paperId;
}
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
}

Entity层类需要使用@Entity注解标注,并用@Table标注实体类对应数据库哪张表,Jpa要求实体类必须要有一个主键,并用@Id标注,使用@GeneratedValue标注主键自增类型,可使用@Coloum标注数据成员对应数据库字段,若没有@Column标注,Jpa会自动根据成员名对应数据库字段,但是这里有一个坑,没有@Cloumn标注的成员名如果使用驼峰命名,Jpa对应时会自动将名字变为小写,并在单词间加上下划线再去对应数据表。Entity类还应提供数据成员的getter和setter。
特别地,如果对应表有多个主键,应新增一个类,其字段对应主键,并实现Serializable接口,如下UserPaperPrimaryKey类,在Entity类中除了使用多个Id标注外还要使用@IdClass标注对应主键类。

@Table(name = "user_paper")
@Entity
@IdClass(UserPaperPrimaryKey.class)
public class UserPaper {
@Id
@Column(name = "userid")
private Integer userId;
@Id
@Column(name = "paperid")
private Integer paperId;
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getPaperId() {
return paperId;
}
public void setPaperId(Integer paperId) {
this.paperId = paperId;
}
}
/**
* {@link UserPaper}的双主键userid,paperid对应构造的主键类
*/
public class UserPaperPrimaryKey implements Serializable {
private Integer userId;
private Integer paperId;
}

Dao层:

@Repository
public interface UserPaperDao extends JpaRepository<UserPaper,Integer> {
/**
* 根据用户id和论文id从用户关联的论文表中删除关联记录
*
* @param userId  the user id 需要删除的用户id
* @param paperId the paper id 要删除的论文id
*/
@Modifying
@Transactional
@Query(value = "delete from user_paper where userId = ?1 and paperId = ?2",nativeQuery = true)
void deleteUserPaperByUserIdAndPaperId(Integer userId,Integer paperId);
}

Dao层接口需要使用@Repository标注,并继承JpaRepository,泛型使用的第一个参数是Dao层对应实体类,第二个是主键类型。继承自JpaRepository可使Dao层自动拥有一些操作数据库的方法。也可在Dao层中使用@Query自定义查询,如果涉及增删改查询,需要使用@Modifying和@Transactional标注,使其能操作数据库。普通的select查询只需@Query 标注,如下:

@Repository
public interface PaperDao extends JpaRepository<Paper,Integer> {

/**
*一些其他SQL接口......
*/
/**
* 根据关键词,标题,摘要模糊查询用户关注论文的所有信息(不包括关键词),并分页
*
* @param userId       the user id 用户id
* @param fuzzyContent the fuzzy content 模糊查询内容
* @param offset       the offset 起始查询位置
* @param pageSize     the page size 单页论文数
* @return the list 分页后的相关论文信息列表,不包括相关关键词
*/
@Query(value = "(select papers.id,papers.title,papers.source,papers.url,papers.publishYear,papers.abstract " +
"from user_paper,papers " +
"where (user_paper.userId = ?1 " +
"and user_paper.paperId = papers.id)" +
"and ( papers.title LIKE ?2 " +
"or papers.abstract LIKE ?2) " +
"union " +
"select papers.id,papers.title,papers.source,papers.url,papers.publishYear,papers.abstract " +
"from user_paper,papers,keywords " +
"where (user_paper.userId = ?1 " +
"and user_paper.paperId = papers.id) " +
"and papers.id = keywords.paperId " +
"and keywords.keyword = ?2) " +
"limit ?3,?4",nativeQuery = true)
List<Paper> fuzzyFindFullUserPaperByKeywordOrTitleOrAbstractAndPage(Integer userId,String fuzzyContent
,Integer offset,Integer pageSize);
}

一些比较复杂的查询效率很重要,使用原生SQL语言就可以自主调整,原本模糊查询有三表连接的要求,而keywords表数据量很大有上万条,使用普通的连接操作效率极低,原本这一句SQL执行需要5s左右,在使用集合操作,对关键处加上括号,减少无效连接,可以大大提升效率。优化后的SQL查询仅需要0.1s左右,从5s到0.1s还是很有成就感的。
Service层:

@Service
public class PaperService {
@Resource
private PaperDao paperDao;
@Resource
private KeywordDao keywordDao;
/**
*其他Service类的方法......
*/

/**
* 通过关键词,标题,摘要模糊查询数据库中相关的论文(包括关键词,及所有相关论文信息,分页返回)
*
* @param originContent the origin content 未经修改的查询内容
* @param pageNum       the page num 要获取的页数
* @param pageSize      the page size 单页论文数量
* @return the list 相关的论文(包括关键词,及所有相关论文信息,分页返回)
*/
public List<PaperWithKeywords> fuzzyFindFullPaperByKeywordOrTitleOrAbstractAndPage(String originContent
,Integer pageNum,Integer pageSize) {
String fuzzyContent = SqlSentenceUtil.splitAndAddFuzzy(originContent);
List<Paper> paperPage = paperDao.fuzzyFindPaperByKeywordOrTitleOrAbstractAndPage(fuzzyContent
,(pageNum - 1) * pageSize,pageSize);
List<PaperWithKeywords> papersWithKeywords = new ArrayList<>();
PaperWithKeywords paperWithKeywords;
for (Paper paper : paperPage) {
paperWithKeywords = new PaperWithKeywords(paper);
paperWithKeywords.setKeywords(keywordDao.findKeywordsByPaperId(paper.getId()));
papersWithKeywords.add(paperWithKeywords);
}
return papersWithKeywords;
}
}

Service层使用@Service标注,@Resource标签用于标注Dao类对象,Spring会自动注入。使用多个Dao类可以实现多个表之间的连接,如以上Service类实现了papers表与keywords表的连接。Service层主要将前端传入数据进行处理,并传入相应Dao对象的方法中进行数据库操作,并将数据进行封装,以返回给Controller。
Controller层:

@RestController
@RequestMapping("/paper")
public class PaperController {
@Resource
private PaperService paperService;
/**
* 模糊查询数据库中相关论文的所有简要信息
*
* @param content the content 要查询的内容
* @return the papers by fuzzy search 相关论文的简要信息
*/
@GetMapping("/search")
public Result getPapersByFuzzySearch(@RequestParam String content) {
return Result.success(paperService.fuzzyFindPaperByKeywordOrTitleOrAbstract(content));
}
/**
*一些其他Controller方法
*/
}

因为使用前后端分离,用JSON数据交互,Controller层需要使用@RestController标注,@RequestMapping可设置对应的后缀,在Controller内部设置的@Get/PostController可进一步设置子后缀。使用@RequestPapram可自动将get请求参数转换为函数参数,使用@RequestBody可将post请求中的json数据自动解析为对象参数。Controller方法return自动会将对象转为JSON。这里使用了Result类可方便的构造返回参数,携带一些处理信息,可使用静态方法success,error构建返回对象,携带信息。

public class Result<T> {
private String code;
private String msg;
private T data;
public Result(T data) {
this.data = data;
}
/**
*一些getter和setter
*/
public static Result success() {
Result result = new Result<>();
result.setCode("0");
result.setMsg("成功");
return result;
}
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>(data);
result.setCode("0");
result.setMsg("成功");
return result;
}
public static Result error(String code, String msg) {
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
return result;
}
}

6.2前端

前端主要是包括几个主要功能的请求函数:

登页的用户相关论文获取

首先判断用户是否登录,确认登录后通过axios请求返回用户相关的论文题目渲染在主页用户列表中

GetUserPaperList: function () {//获取用户相关论文列表
if(this.loginStatus=="true")
{
this.$message({
message: "用户论文列表加载中",
});
let newTitle = {};
let _this = this;
this.$axios
.get(_this.$api.globalUrl + "/userPaper/all", {
params: {},
})
.then(function (response) {
_this.$message({
message: "用户论文列表加载成功",
type: "success",
});
response.data.data.forEach((element) => {
let newTitle = {};
newTitle.id = element.id;//获取论文题目和id以便删除标记
newTitle.title = element.title;
_this.tableData.push(newTitle);
_this.paperNum = _this.tableData.length;//将页数置为返回的数据长度
});
_this.loadingFinished=false;
})
.catch(function (error) {
console.log(error);
_this.$message.error("用户论文列表加载失败");
});
}
else{
this.$message({
message: "登录后即可获取属于你的用户列表!",
});
}
}

主页的添加题目
通过获取输入框的值,发送axios请求获取关于要添加题目的论文数组,塞入论文列表中。

AddTitle: function () {//添加与输入词相关的论文题目
let newTitle = {};
let _this = this;
if (this.searchForm.singleSearchText == "") {
this.$message({
message: "未输入你要查询的题目",
type: "warning",
});
} else {
this.$axios
.get(_this.$api.globalUrl + "/userPaper/add", {
params: {
titleOrigin: _this.searchForm.singleSearchText,
},
})
.then(function (response) {
console.log(response);
_this.$message({
message: "用户论文列表添加成功",
type: "success",
});
response.data.data.forEach((element) => {
let newTitle = {};
newTitle.id = element.id;
newTitle.title = element.title;
_this.tableData.push(newTitle);//将返回的数据加在用户列表后面
});
})
.catch(function (error) {
console.log(error);
_this.$message.error("用户论文列表添加失败");
});
this.searchForm.singleSearchText = "";
}
}

爬取结果论文显示
首先获取到用户侧边栏的搜索值,如果无搜索值则为普通获取用户列表中对应的论文即可,

如果存在搜索值,首先判断是否为在用户列表中搜索,其次若用户列表中不存在,则进行数据库全局搜索。

GetPagePaperList: function (topagenum, topagesize, value) {//获取当页论文列表
let _this = this;
let searchContent = sessionStorage.getItem("searchContent");//获取存在sessionStorage的用户搜索值
if (searchContent == "") {
if (value == true) {//true值为渲染爬取结果列表
this.$axios
.get(_this.$api.globalUrl + "/userPaper/contentsPage", {
params: {
pageNum: topagenum,
pageSize: topagesize,
},
})
.then(function (response) {
_this.paperDetailList = response.data.data;
})
.catch(function (error) {
console.log(error);
_this.$message.error("用户论文列表加载失败");
});
} else {
this.$axios
.get(_this.$api.globalUrl + "/userPaper/keyword", {//渲染关键词相关的论文列表
params: {
keyword: _this.currentKeyword,
pageNum: topagenum,
pageSize: topagesize,
},
})
.then(function (response) {
// console.log(response);
_this.keywordPaperList = response.data.data;
// console.log(_this.keywordPaperList);
})
.catch(function (error) {
console.log(error);
_this.$message.error("用户论文列表加载失败");
});
}
} else {
if(this.fullDatabaseSearch==false)//判断是否为数据库全局搜索
{
this.GetSearchResultPaperList(searchContent);//如果不是且文本不为空则进行结果列表渲染
}
else//否则进行数据库全局搜索
{
this.$axios
.get(_this.$api.globalUrl + "/paper/searchPage", {
params: {
content: searchContent,
pageNum: topagenum,
pageSize: topagesize,
},
})
.then(function (response) {
// console.log(response);
_this.paperDetailList = response.data.data;
// console.log(_this.keywordPaperList);
})
.catch(function (error) {
console.log(error);
_this.$message.error("数据库论文列表加载失败");
});
}
}

获取热词形成热词词谱图

GetKeyword: function () {
let_this = this;
this.$axios
.get(_this.$api.globalUrl + "/keyword/userTopTen", {
params: {},
})
.then(function (response) {
// console.log(response);
_this.keywordList = response.data.data;//返回热词数组
})
.catch(function (error) {
console.log(error);
_this.$message.error("获取关键词失败");
});
}

侧边栏搜索用户论文
在用户列表中搜索论文,若存在于其次若用户列表中不存在,则进行数据库全局搜索。

search: function () {
let _this = this;
this.sidebarPage = "page2";
let sContent = _this.content;
this.$axios
.get(_this.$api.globalUrl + "/userPaper/search", {
params: {
originContent: sContent,
},
})
.then(function (response) {
console.log(response);
response.data.data.forEach((element) => {
let newitem = {};
newitem.id = element.id;
newitem.title = element.title;
_this.resultList.push(newitem);//返回结果数组
});
sessionStorage.setItem("searchContent", sContent.toString());
_this.$emit("GetPagePaperList");
})
.catch(function (error) {
console.log(error);
_this.$message.error("搜索失败");
});
let length=0;
if(this.resultList.length==0)//如果用户内搜索为空是否采用数据库全局搜索
{
this.$confirm("没有搜索到相关题目,是否需要在数据库中继续搜索?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
this.$axios
.get(_this.$api.globalUrl + "/paper/search", {
params: {
content: sContent,
},
})
.then(function (response) {
console.log(response);
response.data.data.forEach((element) => {
let newitem = {};
length++;
newitem.id = element.id;
newitem.title = element.title;
_this.resultList.push(newitem);
});
_this.$emit("GetPagePaperList");
_this.$emit("FullDatabaseResearch",length);//调用父组件的全局搜索函数
})
.catch(function (error) {
console.log(error);
_this.$message.error("搜索失败");
});
})
.catch(() => {
this.$message({
type: "info",
message: "已取消",
});
});
}
this.content = "";
},

获取近年来热度走势
获取近年来热度走势后塞入echarts中

ShowTotalFrequency: function () {//获取热度走势并渲染页面
this.frequencyKeywords.length = 0;
this.frequencyDatas.length = 0;
this.newfrequencyDatas.length = 0;
let _this = this;
this.$axios
.get(_this.$api.globalUrl + "/keywordWithFrequency/all", {
params: {},
})
.then(function (response) {
// console.log(response);
_this.frequencyDatas = response.data.data;
let index = 0;
_this.frequencyDatas.forEach((element) => {
let singleArray = [];
for (let i = 0; i < 21; i++) {
singleArray[i] = "0";
}
element.forEach((ele) => {
let year = parseInt(ele.publishYear) - 2000;
singleArray[year] = ele.frequency.toString();
});
_this.frequencyKeywords.push(element[0].keyword);
if (index == 4) {
_this.frequencyKeywords.push("");
}
_this.newfrequencyDatas.push({
name: element[0].keyword,
type: "line",
data: singleArray,
});//将返回数据塞入echarts中得以渲染
});
_this.drawLine();
})
.catch(function (error) {
console.log(error);
_this.$message.error("热度走势加载失败");
});
}

7心路历程和收获

221801231:

心路历程:

这次最大的心路历程在于不断对自己提高要求,一个小阶段都希望自己可以在某方面做的更好,例如样式再多用一点小图标,再多提升一点用户的使用体验,以及项目错误情况的完善,作为前端这次复习了css,js和http的知识,同时掌握了一个新的框架VUE和新的UI组件ELEMENT UI,同时也在前后端分离与交互方面更加了解。这次结对作业的实现真的很累,占用了很多课余时间,不过这种高强度的工作节奏也是在我之前安逸的大学生活提了个醒,发现自己的不足,希望可以通过一次次的项目更好的武装自己,成为一名更好更优秀的程序员。

收获:

  • 学习新技术
    • VUE框架+axios
    • element UI的使用
    • echarts图表的掌握
    • http和css的复习
  • 学习新工具
    • Postman测试后端接口
    • webstorm编译器
    • node.js以及npm
    • github desktop的使用

22801215

心路程:

这次的结对作业给的时间很少,要学的东西很多,非常具有挑战性,中间还穿插了团队第二次作业。虽然做的时间很短,但是学到的东西很多。做整个项目的过程中一直都很紧张,做项目的每一天都很累。对于这次做的项目内容,我认为自己走的还远远不够,因为自己是第一次接触后端技术,以前都是做偏前端的,仅仅一次的短短的学习和实践肯定还有很多不足。比如这次我在写Dao层的时候没有考虑到复用,每写一个新业务都要在持久层新写一个Query,回头看的时候发现有很多功能其实是可以在Service层结合的,整个框架的思想还是不太了解。虽然写的不怎么样,但是能在这么短的时间内合作完成一个项目,还是很佩服自己的~

收获:

  • 学习新技术
    • 学习了SpringBoot+Jpa框架,学会了一些后端技术
    • 学会部署项目到云服务器,以及linux的一些操作
  • 学习新工具
    • Postman,使用PostPostMan测试后端接口
    • maven,使用maven管理项目结构,并能够使用maven导包

8评价结对队友

221801231 To 221801215:tars还是一如既往的在细节上高要求,并且我们在结对编程的过程中沟通很融洽,这在前后端交互工作的过程中起到了很大的帮助,同时他对我在前端的界面设计方面也提了很多好的意见,他对工作的严谨态度与细致让我也学到了很多,这次结对过程十分有趣。

221801215 To 221801231:感觉我总是能和puffer一拍即合,每次讨论,气氛都很好,而且他还很有规划,感觉这几天拖延症都好多了。这次的项目时间很赶,神经一直都绷得很紧,感觉自己脾气都变得很差,和puffer交流的时候,脾气也不是很好,他能够一直包容我,十分感谢~总之,puffer人很好,很负责任,很有规划,审美也不错,UI设计的也挺好,期待和他的下一次合作。

posted @ 2021-03-31 22:49  Tarsss  阅读(73)  评论(6编辑  收藏