这个作业属于哪个课程 | 2302软件工程社区 |
---|---|
这个作业要求在哪里 | 结对第二次作业——编程实现 |
结对学号 | 222100408冉洋、222100409任思泽 |
这个作业的目标 | 1、编程实现所需功能 2、部署到云服务器 3、撰写博客 |
其他参考文献 | 《构建之法》、CSDN 、Vue文档 、Go语言中文网 |
1. git仓库链接和代码规范链接
git仓库链接
前端开发规范链接
2. PSP表格
PSP | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 15 | 15 |
• Estimate | • 估计这个任务需要多少时间 | 15 | 15 |
Development | 开发 | 1705 | 1595 |
• Analysis | • 需求分析 (包括学习新技术) | 300 | 350 |
• Design Spec | • 生成设计文档 | 40 | 40 |
• Design Review | • 设计复审 | 30 | 30 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 20 | 20 |
• Design | • 具体设计 | 30 | 30 |
• Coding | • 具体编码 | 1000 | 900 |
• Code Review | • 代码复审 | 30 | 20 |
• Test | • 测试(自我测试,修改代码,提交修改) | 90 | 60 |
Reporting | 报告 | 90 | 80 |
• Test Repor | • 测试报告 | 30 | 20 |
• Size Measurement | • 计算工作量 | 15 | 15 |
• Postmortem & Process Improvement Plan | • 事后总结, 并提出过程改进计划 | 30 | 30 |
合计 | 1720 | 1610 |
3. 项目访问链接
4. 成果展示
该成果主要基于世界游泳锦标赛官网设计实现
数据来自66th International Divers’ Day Rostock
所爬取的数据仅用于教学使用❗❗❗
4.1 导航栏(附加功能)
4.1.1 页面跳转
设置一级导航栏,实现不同页面间的切换
4.1.2 页面置顶
设计了一个跳转按钮始终保持在各界面的右下角,单击即可回到该页面的顶部
4.2 首页(附加功能)
介绍了世界游泳锦标赛的举办背景,通过丰富的图文使平台更具吸引力,引起人们对世界游泳锦标赛的兴趣
4.2.1 轮播图
采用轮播图放置宣传照片
-
手动切换:可通过拖拽图片或点击下方的小圆圈实现图片间的切换
-
自动切换:图片间会自动切换,更符合正常网页逻辑
4.2.2 响应式设计
我们使用响应式的页面设计,支持随网页宽度的变化改变页面布局
4.2.3 合作伙伴
设置了一些合作伙伴(赞助商)并提供跳转链接,增加推广热度
4.3 运动员信息
基础功能:展示所有运动员的Country,Athlete,Gender,DOB等信息
附加功能:
-
通过性别筛选运动员
-
通过国籍筛选运动员
-
多条件筛选:可通过性别和国籍多条件筛选运动员
4.4 每日赛程
基础功能:
-
展示每一天的赛程,显示比赛类型(男子1m跳板,女子10m跳台等),参与选手和比赛时间,突出显示决赛
-
支持点击查看详细情况
附加功能:
-
跳转至详细赛况:
从每日赛程中点击某一场比赛即可进入到对应的详细赛况界面
-
通过日历筛选该天赛程
-
通过比赛性质(决赛与非决赛)筛选赛程
-
多条件筛选:可通过日历和比赛性质多条件筛选运动员
4.5 详细赛况
基础功能:展示比赛的成绩,包含本场比赛参赛选手,选手排名,比赛积分,落后积分等
4.6 奖牌榜
基础功能:直观明了地展示所有获奖国家的金银铜牌及奖牌总数的获奖情况并排名
附加功能:设置了一个可交互的环形图,形象具体地展示奖牌在所有获奖国家中的分布情况
4.7 了解更多(附加功能)
4.7.1 赛事新闻
放置了一排的赛事相关新闻,单击即可跳转
4.7.2 宣传视频
设置了两个赛事的宣传视频,支持全屏、静音和倍速播放
4.8 留言(附加功能)
4.7.1 个人留言
输入看法和昵称,后点击提交按钮即可创建一条实时的个人留言
4.7.2 查看所有留言
向下滑动即可查看所有的留言,包括留言的昵称、内容与发布时间,支持留言的分页和跳转
5. 结对讨论过程描述
此次结对作业两人在宿舍里协作完成
-
前期工作
-
前期分工
-
中期分工
-
后期分工
-
查找资料并分享
我们查找到好的文章,会相互分享链接,节省彼此查找资料的时间,提高项目开发的效率。
6. 设计实现过程
6.1功能结构图
6.2系统设计
前端设计:
- 采用的技术:
Vue3
、NaIveUI
、Echarts
- 设计思路:
- 首页: 参照了原型设计时实现的功能以及排版
- 运动员: 参照原型设计的排版,使用表格显示所有选手数据。
- 每日赛程: 可根据日期和比赛类型筛选比赛日程信息,每个比赛可以点击查看详细赛况。
- 详细赛况: 展示所有比赛,可点击查看决赛或半决赛等的具体选手排名。
- 奖牌榜: 展示国家的获奖数排名。
- 扩展功能(部分):
- 奖牌环形图: 使用echarts环形图展示各个国家获奖情况。
- 留言: 用户可以留下自己对网站的看法,留言后会显示在留言板上,也可以看见别人的留言。
- 了解更多: 介绍一些官网的新闻和一些比赛视频。
后端设计:
-
使用的语言:
golang
-
采用的技术:
gin
、viper
-
设计思路: 通过http请求获取前端传入的条件数据,解析参数后,返回json数据,前端通过json数据进行页面渲染。
-
参数校验: 使用
gin
框架的binding
注解,进行参数校验,校验不通过则返回错误信息。 -
数据解析: 使用go官方的
encoding/json
包,将json数据解析成结构体,方便后续处理。 -
数据返回: 使用
gin
框架的c.JSON
方法,将数据以json格式返回给前端。 -
数据持久化 由于数据量较小,所以没有使用数据库,而是直接使用自己编写的缓存工具包,用go的
gob
包进行数据的序列化和反序列化,将数据存储在本地文件中。
6.3遇到的问题以及解决方式
- 前端问题:
问题一:TS中axios的二次封装
解决方式: 因为之前都直接使用js进行开发,使用TS想对axios进行二次封装时,爆了一堆类型错误,最后只得在网上搜索,最后参考这篇文章(https://github.com/kvchen95/blog/blob/master/docs/ts/axios.md)完成了axios的二次封装
- 后端问题:
问题一: 关于数据持久化时,报错gob: type not registered for xxx
解决方式: 经过在网上搜索,发现是因为gob
包在解码时,需要提前注册类型,所以在解码之前,需要先注册类型,即在init
函数中注册需要被序列化和反序列化的结构体类型
- 前后端交互问题:
问题一:前端跨域访问后端问题
解决方式:
- 前端: 在
vue.config.ts
中配置proxy
,将前端请求代理到后端地址
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
- 后端: 在
gin
框架中使用cors
中间件,允许前端跨域访问
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Origin", "Authorization", "Content-Type"},
}))
7. 代码说明
7.1 前端代码说明
/src/router/index.ts
- 使用
vue-router
实现路由跳转,通过createRouter
创建路由实例,通过createWebHistory
创建路由模式,通过createRouter
的routes
属性设置路由路径和对应的组件 - 路由拦截器中添加了
loadingBar
的start
和finish
方法,实现路由跳转时的加载动画
import { createRouter, createWebHistory } from 'vue-router'
import { createDiscreteApi, type LoadingBarApi } from 'naive-ui'
import { useTheme } from '@/stores/theme'
import { nextTick } from 'vue'
let loadingBar: LoadingBarApi
nextTick(() => {
const { themeColor } = useTheme()
const { loadingBar: bar } = createDiscreteApi(['loadingBar'], {
loadingBarProviderProps: {
themeOverrides: {
colorLoading: themeColor
}
}
})
loadingBar = bar
})
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '',
name: 'home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/about',
name: 'about',
component: () => import('@/views/AboutView.vue')
},
{
path: '/athletes',
name: 'athletes',
component: () => import('@/views/AthletesView.vue')
},
{
path: '/schedule',
name: 'schedule',
component: () => import('@/views/ScheduleView.vue')
},
{
path: '/results/:id?',
name: 'results',
component: () => import('@/views/ResultsView.vue')
},
{
path: '/medal',
name: 'medal',
component: () => import('@/views/MedalView.vue')
},
{
path: '/comment',
name: 'comment',
component: () => import('@/views/CommentView.vue')
}
]
})
router.beforeEach(() => {
loadingBar?.start()
})
router.afterEach(() => {
loadingBar?.finish()
})
export default router
/src/utils/request.ts
二次封装axios,实现请求拦截和响应拦截,实现请求的统一处理
import axios from "axios"
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
export interface Response<T = any> {
code: number
data: T
msg: string
error: string
}
export interface RequestOptions {
showLoadingBar?: boolean
errorCallBack?: (error: Response) => void
showErrorMessage?: boolean
}
// 拓展自定义请求配置
interface ExpandAxiosRequestConfig<D = any> extends AxiosRequestConfig<D> {
interceptorHooks?: InterceptorHooks
requestOptions?: RequestOptions
}
export interface RequestConfig<D = any> extends AxiosRequestConfig<D> {
requestOptions?: RequestOptions
}
// 拓展axios请求配置
interface ExpandInternalAxiosRequestConfig<D = any> extends InternalAxiosRequestConfig<D> {
intercetorHooks?: InterceptorHooks
requestOptions?: RequestOptions
}
// 拓展axios响应配置
interface ExpandAxiosResponse<T = any, D = any> extends AxiosResponse<T, D> {
config: ExpandInternalAxiosRequestConfig<D>
}
export interface InterceptorHooks {
requestInterceptor?: (config: ExpandInternalAxiosRequestConfig) => ExpandInternalAxiosRequestConfig
requestInterceptorCatch?: (config: ExpandInternalAxiosRequestConfig) => any
responseInterceptor?: (response: ExpandAxiosResponse) => AxiosResponse | Promise<AxiosResponse>
responseInterceptorCatch?: (response: ExpandAxiosResponse) => any
}
const transform: InterceptorHooks = {
requestInterceptor: (config) => {
if (config.requestOptions?.showLoadingBar) {
// TODO show loading bar
}
return config
},
requestInterceptorCatch: (config) => {
if (config.requestOptions?.showErrorMessage) {
// TODO show error message
}
return Promise.reject(config)
},
responseInterceptor: (response) => {
if (response.config.requestOptions?.showLoadingBar) {
// TODO hide loading bar
}
if (response.data.code !== 0) {
// 请求出现业务错误
if (response.config.requestOptions?.showErrorMessage) {
// TODO show error message
}
if (response.config.requestOptions?.errorCallBack) {
response.config.requestOptions.errorCallBack(response.data)
}
return Promise.reject(response.data)
}
// 请求成功, 直接返回自定义返回类型的data
return response.data.data
},
responseInterceptorCatch: (err) => {
if (err.config.requestOptions?.showLoadingBar) {
// TODO hide loading bar
}
if (err.config.requestOptions?.showErrorMessage) {
// TODO show error message
}
return Promise.reject(err.config)
}
}
class Request {
private _instance: AxiosInstance
private _defaultConfig: ExpandAxiosRequestConfig = {
baseURL: '/api',
timeout: 5000,
requestOptions: {
showLoadingBar: true,
showErrorMessage: true
}
}
private _interceptorHooks?: InterceptorHooks
constructor(config: ExpandAxiosRequestConfig) {
this._instance = axios.create(Object.assign(this._defaultConfig, config))
this._interceptorHooks = config.interceptorHooks
this.setupInterceptors()
}
private setupInterceptors() {
this._instance.interceptors.request.use(this._interceptorHooks?.requestInterceptor, this._interceptorHooks?.requestInterceptorCatch)
this._instance.interceptors.response.use(this._interceptorHooks?.responseInterceptor, this._interceptorHooks?.responseInterceptorCatch)
}
public request(config: RequestConfig): Promise<AxiosResponse> {
return this._instance.request(config)
}
public get<T = any>(url:string, config?:RequestConfig): Promise<T> {
return this._instance.get(url, config)
}
public post<T = any>(url:string, data?:any, config?:RequestConfig): Promise<T> {
return this._instance.post(url, data, config)
}
public delete<T = any>(url:string, config?:RequestConfig): Promise<T> {
return this._instance.delete(url, config)
}
public put<T = any>(url:string, data?:any, config?:RequestConfig): Promise<T> {
return this._instance.put(url, data, config)
}
}
const instance = new Request({
interceptorHooks: transform
})
export const request: (config: RequestConfig) => Promise<AxiosResponse> = (config) => instance.request(config)
export const get: <T = any>(url:string, config?:RequestConfig) => Promise<T> = (url, config) => instance.get(url, config)
export const post: <T = any>(url:string, data?:any, config?:RequestConfig) => Promise<T> = (url, data, config) => instance.post(url, data, config)
export const del: <T = any>(url:string, config?:RequestConfig) => Promise<T> = (url, config) => instance.delete(url, config)
export const put: <T = any>(url:string, data?:any, config?:RequestConfig) => Promise<T> = (url, data, config) => instance.put(url, data, config)
/src/layout/LayoutView.vue
使用NaiveUI
的Layout
组件实现页面布局,包括Header
、Content
、Footer
,并在Content
中使用n-back-top
组件实现返回顶部功能
<template>
<n-layout >
<n-layout-header bordered class="top-0 z-50" position="absolute">
<HeaderView />
</n-layout-header>
<n-layout-content ref="layoutRef">
<ContentView>
</ContentView>
</n-layout-content>
<n-layout-footer>
<FooterView></FooterView>
</n-layout-footer>
</n-layout>
<n-back-top :right="100" :listen-to="layoutRef" />
</template>
<script setup lang="ts">
import HeaderView from './header/HeaderView.vue'
import FooterView from './footer/FooterView.vue'
import ContentView from './content/ContentView.vue'
import { ref } from 'vue'
const layoutRef = ref(null)
</script>
7.2 后端代码说明
/routes/route.go
使用gin
框架实现路由的注册,通过gin
框架的Group
方法实现路由的分组,通过GET
和POST
方法实现不同请求方式的路由注册
func Init() *gin.Engine {
if conf.AppConf.Dev {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(ginlogger.Logger())
r.Use(ginlogger.Recovery())
base := r.Group(conf.ServerConf.Prefix)
{
base.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
base.GET("/athletes", controller.GetAthletes)
base.GET("/results", controller.GetResults)
base.GET("/results/:id", controller.GetResultDetail)
base.GET("/schedule", controller.GetSchedule)
base.GET("/medals", controller.GetMedals)
base.GET("/comments/:pageNo", controller.ListComment)
base.POST("/comments", controller.CreateComment)
}
return r
}
/routes/controller/athlete.go
实现了获取运动员信息的接口,通过c.JSON
方法返回json数据
func GetAthletes(c *gin.Context) {
var service athletes.AthleteService
res := service.GetAthletes()
c.JSON(http.StatusOK, res)
}
/service/athletes/athlete.go
解析json数据的结构体,通过json.Unmarshal
方法解析json数据
var athleteJsonFile = "/athletes.json"
type Athlete struct {
Country string `json:"country"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Gender string `json:"gender"`
Dob string `json:"dob"`
CountryCode string `json:"countryCode"`
}
type AthleteService struct {
}
type CountryJSON struct {
CountryName string `json:"CountryName"`
CountryCode string `json:"CountryCode"`
AthleteList []AthleteJSON `json:"Participations"`
}
type AthleteJSON struct {
Gender int `json:"Gender"`
LastName string `json:"PreferredLastName"`
FirstName string `json:"PreferredFirstName"`
Dob string `json:"DOB"`
}****
init方法注册结构体类型,通过os.ReadFile
方法读取json文件,通过json.Unmarshal
方法解析json数据,将解析后的数据存入缓存中,通过cache.Set
方法存入缓存
func init() {
gob.Register([]Athlete{})
}
func (service *AthleteService) GetAthletes() serializer.Response {
res, ok := cache.Get("athletes")
if ok {
return serializer.Response{
Data: res,
}
}
countryList := make([]CountryJSON, 0)
bytes, err := os.ReadFile(conf.AppConf.StaticPath + athleteJsonFile)
if err != nil {
return serializer.AppError("Failed to read athletes.json", err)
}
err = json.Unmarshal(bytes, &countryList)
if err != nil {
return serializer.AppError("Failed to parse athletes.json", err)
}
athleteRes := make([]Athlete, 0)
for _, country := range countryList {
for _, athlete := range country.AthleteList {
athleteRes = append(athleteRes, Athlete{
Country: country.CountryName,
CountryCode: country.CountryCode,
FirstName: athlete.FirstName,
LastName: athlete.LastName,
Gender: parseGender(athlete.Gender),
Dob: athlete.Dob,
})
}
}
_ = cache.Set("athletes", athleteRes, 0)
return serializer.Response{
Data: athleteRes,
}
}
func parseGender(code int) string {
if code == 1 {
return "Female"
} else {
return "Male"
}
}
8. 心路历程和收获
-
冉洋(222100408):
之前写前端都是使用js,这次我选择使用ts进行开发, 因为这更加贴合现代前端开发的趋势,同时也能提高代码的可维护性和可读性。在实际的开发过程中,我发现ts的类型检查和接口定义能够帮助我更好地理解和管理代码,减少了一些潜在的bug。
但是我也遇到了一些困难和技术挑战。特别是在设计页面布局和实现交互功能时,我花费了不少时间和精力。然而,在逐步解决这些问题的过程中,我收获了很多宝贵的经验和技能。
这次选择了Naive UI作为UI框架,这是一个基于Vue3的组件库,提供了丰富的组件和样式,能够帮助我快速搭建页面。这也是我第一次使用这个UI框架。
此外通过与我的搭档紧密合作,我也提高了自己的团队合作和沟通能力。
我相信这次结对作业的经验和收获将在我未来的职业发展中发挥重要作用。
-
任思泽(222100409):
在项目开始之前,我对于所需的技术栈和工具并不熟悉,但我抱着积极的态度和渴望学习的心情迎接这个挑战。
听说Go开发Web的效率很高,所以我选择了Go语言作为后端开发的技术栈。在实际的开发过程中,我发现Go语言的简洁和高效确实让我受益匪浅。通过学习和实践,我逐渐掌握了Go语言的基本语法和常用库,能够独立完成后端开发的任务。
在项目中,我还学习了如何使用gin框架来搭建Web服务,以及如何与前端进行数据交互。通过这些实践,我对Web开发的整个流程有了更深入的了解,也提高了自己的编程能力和实践经验。
此外,这次项目也让我更加注重细节和质量。我意识到编程实现的过程中,细致入微的工作和严谨的态度对确保程序的正确性和稳定性来说是非常重要的。
这些经验和收获将对我的未来发展产生积极的影响。
9. 评价结对队友
- 冉洋(222100408): 我认为任思泽是一位非常出色的队友。他展现了卓越的技术能力和团队合作精神。作为后端开发工程师,他对后端技术有深入的理解,并能够准确理解和满足我的后端需求。他在与我合作过程中表现出色,能够高效地开发后端接口。他的代码规范和对用户体验的关注使整个项目进展顺利。我非常欣赏他的团队协作能力和与我密切合作的态度。他是一个可靠的合作伙伴,我相信我们的合作将继续创造更多的价值。希望任思泽能够继续发展自己的技术能力,并保持对团队合作的热情。
- 任思泽(222100409): 我认为冉洋是一位非常出色的队友。他拥有较为熟练的前端技能,能够很好的完成页面的设计以及开发工作, 期待与他的再次合作。