个人中心
需求
个人资料显示,包括昵称、头像、bio
个人统计API
修改资料/密码
热力图
个人资料展示
添加字段
首先在后端/accounts/models.py中user内添加新字段:
1 2 3 nickname = models.CharField(max_length=20 , blank=True ) avatar_url = models.URLField(blank=True ) bio = models.TextField(max_length=200 , blank=True )
并且在serializers里添加对应字段,并单独点出不允许前端修改的字段
1 2 fields = ("id" , "username" , "email" , "role" , "solved_count" , "submit_count" , "nickname" , "bio" , "avatar_url" ) read_only_fields = ("id" , "username" , "role" , "solved_count" , "submit_count" )
然后执行迁移
添加serializer
接下来添加接口:
1 2 GET /api/auth/me/ PATCH /api/auth/me/
分别对应查看当前用户资料和修改当前用户资料
在view种修改MeView类为RetrieveUpdateAPIView,这样可以支持PATCH
我们使用PATCH更新信息
添加修改密码类
在serializers中添加修改密码接口,增加一个修改密码类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class ChangePasswordSerializer (serializers.Serializer): old_password = serializers.CharField(write_only = True ) new_password = serializers.CharField(write_only = True , minlen = 6 ) def validate_new_password (self, value ): validate_password(value) return value def validate (self, attrs ): user = self .context['request' ] if not user.check_password(attrs['old_passworl' ]): raise serializers.ValidationError({"old_password" :"旧密码不正确" }) return attrs def save (self, **kwargs ): user = self .context["request" ].US=user user.set_password(self .validated_data['new_password' ]) user.save(update_fields=["password" ]) return user
这里使用了check_password,因为django中密码哈希保存,不能直接比较字符串
后面设置新密码时,使用set_password,可以加密密码
validate用于校验,使用了默认的validate_password校验规则,来自setting.py的AUTH_PASSWORD_VALIDATORS
在view.py中添加对应的view
1 2 3 4 5 6 7 8 9 class ChangePasswordView (generics.GenericAPIView): serializer_class = ChangePasswordSerializer permission_classes = [IsAuthenticated] def post (self, request ): serializer = self .get_serializer(data = request.data) serializer.is_valid(raise_exception = True ) serializer.save() return Response({"detail" :"修改成功" })
在url.py中添加对应的路径,得到最终的接口POST /api/auth/change-password/
1 path('change-password' , ChangePasswordView.as_view(), name="change-password" ),
个人信息统计
在views.py内添加对应的视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class MeStatsView (generics.GenericAPIView): permission_classes = [IsAuthenticated] def get (self, request ): user = request.user submissions = Submission.objects.filter (user=user) accpeted_submissions = submissions.filter (status=Submission.Status.ACCEPTED) solved_problems_ids = accpeted_submissions.values_list("problem_id" , flat=True ).distinct() solved_count = solved_problems_ids.count() difficulty_stats = { "easy" : accpeted_submissions.filter (problem__difficulty="easy" ).distinct().count(), "medium" : accpeted_submissions.filter (problem__difficulty="medium" ).distinct().count(), "hard" : accpeted_submissions.filter (problem__difficulty="hard" ).distinct().count(), } heatmap = ( accpeted_submissions.annotate(date=TruncDate("created_at" )).values("date" ).annotate(count=Count("id" )).order_by("date" ) ) return Response( { "submit_count" : submissions.count(), "accpeted_count" : accpeted_submissions.count(), "solved_count" : solved_count, "difficulty" : difficulty_stats, "heatmap" : [ {"date" : item["date" ].isoformat(), 'count' : item["count" ]} for item in heatmap ] } )
submit_count指用户总提交次数
accepted_count指用户AC提交次数
solved_count指用户通过题目数量(多次AC算一次)
heatmap实现按照日期统计AC题目数量
在urls.py中添加对应接口,得到最终的接口GET /api/auth/me/stats/
1 path('me/stats/' , MeStatsView.as_view(), name="me-stats" ),
个人资料编辑
修改基础资料
前端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 const editing = ref (false ) const saving = ref (false ) const saveMessage = ref ('' ) const authStore = useAuthStore () function fillProfileForm ( ) { if (!user.value ) { return } profileForm.value = { email : user.value .email , nickname : user.value .nickname , avatar_url : user.value .avatar_url , bio : user.value .bio , } } async function handleSaveProfile ( ) { saving.value = true saveMessage.value = '' try { const response = await updateProfile (profileForm.value ) user.value = response.data authStore.user = response.data fillProfileForm () editing.value = true saveMessage.value = "个人资料已更新" } catch (error) { saveMessage.value = "保存失败" } finally { saving.value = false } }
我们在profilevue中添加了修改个人信息的表单,保存后调用updateProfile函数,这里对应的接口在auth中如下:
1 2 3 export function updateProfile (data : UpdateProfileRequest ) { return http.patch <User >('/auth/me/' , data) }
API为:PATCH /api/auth/me/
请求体类似:
1 2 3 4 5 6 7 export interface UpdateProfileRequest { email : string nickname : string avatar_url : string bio : string }
后端:
1 2 3 4 5 6 class MeView (generics.RetrieveUpdateAPIView): serializer_class = UserSerializer permission_classes = [IsAuthenticated] def get_object (self ): return self .request.user
MeView修改为generics.RetrieveUpdateAPIView,可以支持PATCH和GET
serializer配置了readonlyfields,某些字段无法修改
修改密码
前端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 const changingPassword = ref (false )const passwordMessage = ref ('' )const passwordEditing = ref (false )const router = useRouter ()const passwordForm = ref ( { old_password : '' , new_password : '' , } ) async function handleChangePassword ( ) { changingPassword.value = true passwordMessage.value = '' try { const response = await ChangePassword (passwordForm.value ) passwordEditing.value = true passwordMessage.value = response.data .detail passwordForm.value = { old_password : '' , new_password : '' , } authStore.logout () router.push ('/login' ) } catch (error) { passwordMessage.value = '密码修改失败' } finally { changingPassword.value = false }
profileview.vue中有对应的密码表单,点击后调用handlechangePassword函数,在这里面修改密码后重新登录
API:POST /api/auth/change-password/
请求体类似:
1 2 3 4 5 export interface ChangePasswordRequest { old_password : string new_password : string }
后端:
Serializer做了三种操作:
1 2 old_password = serializers.CharField(write_only = True ) new_password = serializers.CharField(write_only = True , min_length = 6 )
1 2 3 4 5 6 def validate (self, attrs ): user = self .context['request' ].user if not user.check_password(attrs['old_password' ]): raise serializers.ValidationError({"old_password" :"旧密码不正确" }) return attrs
1 2 3 4 5 def save (self, **kwargs ): user = self .context["request" ].user user.set_password(self .validated_data['new_password' ]) user.save(update_fields=["password" ]) return user
密码不能直接比较,需要用check_password()和set_password()来验证旧密码和保存新密码
views中接受pos
1 2 3 4 5 6 7 8 9 10 class ChangePasswordView (generics.GenericAPIView): serializer_class = ChangePasswordSerializer permission_classes = [IsAuthenticated] def post (self, request ): serializer = self .get_serializer(data = request.data) serializer.is_valid(raise_exception = True ) serializer.save() return Response({"detail" :"修改成功" })
热力图
1 2 3 4 heatmap_counts = defaultdict(int ) for created_at in accepted_submissions.exclude(created_at__isnull=True ).values_list("created_at" , flat=True ): date_text = timezone.localtime(created_at).date().isoformat() heatmap_counts[date_text]+=1
后端views中,创建了字典用于存储热力图的日期与当前日期中提交且AC的数量,并在response中返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 function formatDate (date : Date ) { const year = date.getFullYear () const month = String (date.getMonth ()+1 ).padStart (2 , '0' ) const day = String (date.getDate ()).padStart (2 ,'0' ) return `${year} -${month} -${day} ` } const heatmapCells = computed (()=> { const countMap = new Map (stats.value ?.heatmap .map ((item )=> [item.date .slice (0 ,10 ), item.count ])?? [],) const days = 90 const cells = [] const today = new Date () for (let i = days-1 ;i>=0 ;i--) { const date = new Date (today) date.setDate (today.getDate ()-i) const dateText = formatDate (date) cells.push ( { date :dateText, count :countMap.get (dateText)??0 , } ) } return cells }) function getHeatmapClass (count : number ) { if (count===0 ) return 'heatmap-empty' if (count===1 ) return 'heatmap-low' if (count<=3 ) return 'heatmap-medium' return 'heatmap-high' }
前端在ts中写热力图渲染的逻辑
设置显示九十天,按照每天一个方格创建元素并推入数组
热力图根据count数量显示对应css样式,实现切换颜色
前端
补充类型
在/types/api.ts中补充需要用到的类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 export interface HeatmapItem { date : string count : number } export interface UserStats { submit_count : number accepted_count : number solved_count : number difficulty : { easy : number medium : number hard : number } heatmap : HeatmapItem [] }
添加路由
在/api/auth.ts中添加路由
1 2 3 4 5 export function getMeStats ( ){ return http.get <UserStats >('/auth/me/stats/' ) }
设计页面
创建ProfileView.vue,这里只展示ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 <script setup lang="ts"> import { onMounted, ref } from 'vue'; import { getMe,getMeStats } from '../api/auth'; import type { User, UserStats } from '../types/api'; const user = ref<User|null>(null) const stats = ref<UserStats|null>(null) const loading = ref(false) const errorMessage = ref('') async function loadProfile() { loading.value = true errorMessage.value = '' try { const [userResponse, statsResponse] = await Promise.all( [ getMe(), getMeStats(), ] ) user.value = userResponse.data stats.value = statsResponse.data } catch (error) { errorMessage.value = '个人信息加载失败' } finally { loading.value = false } } onMounted(()=> { loadProfile() }) </script>
这里获取数据用到了Promise异步操作,因为我们发送了两个请求,获取两份数据,用到了promise.all()返回一个数组,分别为两份请求的结果
我们需要同时获取这两份请求,而不是先获取一个再获取一个,他们都依赖accesstoken且不需要等彼此的结果
添加路由
/router/index.ts中添加路由
1 2 3 4 5 6 7 8 9 { path : '/profile' , name : 'profile' , component : ProfileView , meta : { requiresAuth : true }, },
实现C/CPP判题
CPP与python不同,前者需要先经过编译,生成可执行文件后拿每个测试点去运行才会出结果
统一结果格式
1 2 3 4 5 6 @dataclass class RunResult : status: str output: str = "" error_message: str = "" time_used: int = 0
把python,cpp的运行结果统一格式
docker沙箱
为了防止本地编译环境出现问题,我们需要将cpp的判题放在docker沙箱内隔离运行
用到的命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def docker_base_command (temp_dir ): return [ "docker" , "run" , "--rm" , "--network" , "none" , "--memory" , "128m" , "--cpus" , "1" , "--pids-limit" , "64" , "-v" , f"{temp_dir} :/sandbox" , "-w" , "/sandbox" , "gcc:13" , ]
这一部分是docker的基础运行指令
docker run:启动一个临时容器。
–rm:运行结束后自动删除容器。
–network none:禁止联网,防止用户代码访问网络。
–memory 128m:限制内存 128MB。
–cpus 1:限制 CPU。
–pids-limit 64:限制进程数量。
-v temp_dir:/sandbox:把本机临时目录挂载到容器里的
/sandbox。
-w /sandbox:容器启动后默认工作目录是 /sandbox。
gcc:13:使用带 gcc/g++ 的 Docker 镜像。
运行docker沙箱
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 def run_docker_command (temp_dir, command, input_data="" , timeout_seconds=5 ): '''执行dockers命令''' started_at = time.perf_counter() try : completed = subprocess.run( docker_base_command(temp_dir) + ["bash" , "-lc" , command], input =input_data, text=True , capture_output=True , timeout=timeout_seconds, ) except FileNotFoundError: time_used = int ((time.perf_counter() - started_at) * 1000 ) return RunResult( status=Submission.Status.SYSTEM_ERROR, error_message="Docker command not found. Please install Docker and make sure it is in PATH." , time_used=time_used, ) except subprocess.TimeoutExpired: time_used = int ((time.perf_counter() - started_at) * 1000 ) return RunResult(status=Submission.Status.TIME_LIMIT_EXCEEDED, time_used=time_used) time_used = int ((time.perf_counter() - started_at) * 1000 ) if completed.returncode != 0 : return RunResult( status=Submission.Status.RUNTIME_ERROR, output=completed.stdout, error_message=completed.stderr[-2000 :], time_used=time_used, ) return RunResult( status=Submission.Status.ACCEPTED, output=completed.stdout, time_used=time_used, )
执行计时,执行子进程判题等操作
处理情况:docker找不到文件返回SE,运行超时TLE,返回非0RE,正常返回
编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def get_source_name (language ): if language == Submission.Language.C: return "main.c" return "main.cpp" def get_compile_command (language ): if language == Submission.Language.C: return "gcc main.c -O2 -std=c11 -o main" return "g++ main.cpp -O2 -std=c++17 -o main" def compile_c_code (temp_dir, language ): result = run_docker_command( temp_dir=temp_dir, command=get_compile_command(language), timeout_seconds=10 , ) if result.status == Submission.Status.RUNTIME_ERROR: result.status = Submission.Status.COMPILE_ERROR return result
根据传入的语言,前两个函数返回对应的源代码文件名与编译指令
compile_c_ode用于调用docker编译命令,若是编译这里出现了错误,原本一致返回RE这里会替换为CE
运行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def run_c_code (code, language, test_cases, timeout_seconds ): with tempfile.TemporaryDirectory() as temp_dir: source_path = Path(temp_dir) / get_source_name(language) source_path.write_text(code, encoding="utf-8" ) compile_result = compile_c_code(temp_dir, language) if compile_result.status != Submission.Status.ACCEPTED: return compile_result, [] results = [] for test_case in test_cases: result = run_docker_command( temp_dir=temp_dir, command="./main" , input_data=test_case.input_data, timeout_seconds=timeout_seconds, ) results.append((test_case, result)) return None , results
运行流程:
创建临时目录
写入 main.c 或 main.cpp
调用 Docker 编译
如果编译失败,直接返回编译错误
如果编译成功,对每个测试点运行 ./main
判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def create_judge_result (submission, test_case, run_result, status ): '''创建格式化的评测结果''' JudgeResult.objects.create( submission=submission, testcase=test_case, status=status, time_used=run_result.time_used, memory_used=0 , output=run_result.output[-2000 :], error_message=run_result.error_message, ) def judge_single_case (submission, test_case, run_result ): '''判断单个测试点是否正确''' status = run_result.status if status == Submission.Status.ACCEPTED: if normalize_output(run_result.output) != normalize_output(test_case.output_data): status = Submission.Status.WRONG_ANSWER create_judge_result(submission, test_case, run_result, status) return status
判断单个测试点是否正确,即区分AC与WA
判题总流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 def judge_submission (submission ): '''判题入口''' test_cases = list (submission.problem.test_cases.all ()) if not test_cases: submission.status = Submission.Status.SYSTEM_ERROR submission.error_message = "该题目没有测试点。" submission.judged_at = timezone.now() submission.save(update_fields=["status" , "error_message" , "judged_at" ]) return submission timeout_seconds = max (1 , submission.problem.time_limit / 1000 ) total_score = 0 max_time = 0 final_status = Submission.Status.ACCEPTED final_error = "" JudgeResult.objects.filter (submission=submission).delete() if submission.language == Submission.Language.PYTHON3: case_results = [ ( test_case, run_python_code(submission.code, test_case.input_data, timeout_seconds), ) for test_case in test_cases ] elif submission.language in (Submission.Language.C, Submission.Language.CPP): compile_error, case_results = run_c_code( submission.code, submission.language, test_cases, timeout_seconds, ) if compile_error is not None : submission.status = compile_error.status submission.score = 0 submission.time_used = compile_error.time_used submission.error_message = compile_error.error_message submission.judged_at = timezone.now() submission.save( update_fields=["status" , "score" , "time_used" , "error_message" , "judged_at" ] ) return submission else : submission.status = Submission.Status.SYSTEM_ERROR submission.error_message = f"Unsupported language: {submission.language} " submission.judged_at = timezone.now() submission.save(update_fields=["status" , "error_message" , "judged_at" ]) return submission for test_case, run_result in case_results: status = judge_single_case(submission, test_case, run_result) if status == Submission.Status.ACCEPTED: total_score += test_case.score max_time = max (max_time, run_result.time_used) if status != Submission.Status.ACCEPTED: final_status = status final_error = run_result.error_message submission.status = final_status submission.score = 100 if final_status == Submission.Status.ACCEPTED else total_score submission.time_used = max_time submission.error_message = final_error submission.judged_at = timezone.now() submission.save( update_fields=["status" , "score" , "time_used" , "error_message" , "judged_at" ] ) return submission
这里是整个判题的入口
首先拿到所有的测试点,若没有测试点返回SE
然后根据传入的语言进入不同分支,对每个测试点跑测试
其中python只要调用run_python_code即可
cpp需要先跑一遍编译run_c_code,再遍历跑所有测试点judge_single_case
若所有都AC则最终返回AC和100分,否则返回对应错误状态和已通过的分数
部署完docker后,运行时保持Docker开启,c和cpp就会走docker端
前端
1 2 3 4 5 <select v-model="submitLanguage"> <option value="python3">Python3</option> <option value="c">C</option> <option value="cpp">C++</option> </select>
添加表单,并把api/types中的类型由默认的python3设置为Language类型,并自定义language类型为现在的三种语言
首页
做一个首页,用来展示一些信息,包括每日一题等
公告app
创建app
后端执行
1 python manage.py startapp announcements apps/announcements
创建基础框架,并注册到settings.py中
写入models.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from django.db import modelsclass Announcement (models.Model): title = models.CharField(max_length=100 ) content = models.TextField() is_active = models.BooleanField(default=True ) is_pinned = models.BooleanField(default=False ) created_at = models.DateTimeField(auto_now_add=True ) class Meta : ordering = ["-is_pinned" , "-created_at" ] def __str__ (self ): return self .title
字段包括:
title:标题
content:正文
is_active:是否展示
is_pinned:是否指定
created_at:创建时间,自动生成
ordering:默认排序
注册admin
在admin.py中写入
1 2 3 4 5 6 7 8 9 from django.contrib import adminfrom .models import Announcement@admin.register(Announcement ) class AnnouncementAdmin (admin.ModelAdmin): list_display = ("id" , "title" , "is_active" , "is_pinned" , "created_at" ) list_filter = ("is_active" , "is_pinned" ) search_fields = ("title" , "content" )
这样可以在admin后台新增公告
迁移并建表
1 2 python manage.py makemigrations announcements python manage.py migrate
构建api出口
首先创建serializers.py,将模型转换为json
1 2 3 4 5 6 7 8 9 10 class AnnouncementSerializer (serializers.ModelSerializer): class Meta : model = Announcement fields = [ "id" , "title" , "content" , "is_pinned" , "created_at" , ]
这里不暴露is_active,这里需要后台控制
然后修改views.py,决定返回哪些公告
1 2 3 4 5 6 class AnnouncementListView (generics.ListAPIView): serializer_class = AnnouncementSerializer permission_classes = [AllowAny] def get_queryset (self ): return Announcement.objects.filter (is_active=True )
使用DRF的ListAPIView,适合只读列表接口
他用于查询数据,调用serializer并返回json
1 return Announcement.objects.filter (is_active =True )
表示只返回启用中的公告
接下来创建urls.py,表示模块内部路由
1 2 3 urlpatterns = [ path("" , AnnouncementListView.as_view(), name="announcement-list" ), ]
这里“”是相对于上一级路径的空路径,在总路由里添加:
path("api/announcements/", include("apps.announcements.urls")),
将所有/api/announcements/开头的分给apps.announcements.urls
主页app
创建app
用相同指令创建框架,但是homeapp没有models,因为它的数据来自于已有模块,当然admin也没有
因此home只需要查表,组合数据并返回前端
创建api出口
创建serializers.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class HomeTagSerializer (serializers.ModelSerializer): class Meta : model = Tag fields = ["id" ,"name" ] class HomeProblemSerializer (serializers.ModelSerializer): '''每日一题转换json,轻量级展示题目信息''' tags = HomeTagSerializer(many=True , read_only=True ) class Meta : model = Problem fields = [ "id" , "title" , "difficulty" , "time_limit" , "memory_limit" , "tags" , ] class HomeAnnouncementSerializer (serializers.ModelSerializer): '''公告内容转换json''' class Meta : model = Announcement fields = [ "id" , "title" , "content" , "is_pinned" , "created_at" , ]
这里重新引用了problem,并减少了需要的字段,因为首页里只需要更少的字段,所以我们可以有不同的serializer
修改views.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class HomeView (APIView ): '''首页接口,返回每日一题、未完成的题目和公告''' def get (self, request ): '''获取每日一题,未完成题目与公告''' daily_problem = Problem.objects.filter (is_public=True ).order_by("?" ).first() unfinished_problems = Problem.objects.filter (is_public=True ) if request.user.is_authenticated: solved_problem_ids = Submission.objects.filter ( user = request.user, status = Submission.status.ACCEPTED ).values_list("problem_id" , flat=True ) unfinished_problems = unfinished_problems.exclude(id__in=solved_problem_ids) unfinished_problems = unfinished_problems.order_by("id" )[:5 ] announcements = Announcement.objects.filter (is_active=True )[:5 ] return Response( { "daily_problem" : HomeProblemSerializer(daily_problem).data if daily_problem else None , "unfinished_problems" : HomeProblemSerializer( unfinished_problems, many=True , ).data, "announcements" : HomeAnnouncementSerializer( announcements, many=True , ).data, } )
这里是主要聚合逻辑
get函数用于获取指定数量的题目,公告等,这里使用ListAPIView,因为我们返回的是一种自定义结构,用APIView合适
每日一题部分用于从公开题目中随机获取一道题,order_by("?")表示随机排序
取未完成题目时,先获取所有公开题目,如果用户登录了,查询该用户ac过哪些题,然后把用户ac过的题排除掉,取前五道未完成的题目
如果用户没登录就不执行,所以未登录用户看到的就是公开题目前五道
公告只取启用公告,展示前五条,新公告优先
最后返回response,将上面三部分组成一个json
前端
HomeView.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const homeData = ref<HomeData |null >(null )const loading = ref (false )const errorMessage = ref ('' )async function loadHomeData ( ){ loading.value = true errorMessage.value = '' try { const response = await getHomeData () homeData.value = response.data } catch (error) { errorMessage.value = "首页加载失败" } finally { loading.value = false ; } } onMounted (()=> { loadHomeData () })
home.ts
1 2 3 4 export function getHomeData ( ){ return http.get <HomeData >("/home/" ) }
题解
题解写在problemdetail中,但是是独立的一个模块,这个模块用于储存题解,以及用户自己发布题解
由于题目和题解是一对多的,所以我们不能直接放到Problem模型中
模型
首先创建一个新app并注册路由,添加字段如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 from django.db import modelsfrom django.conf import settingsfrom apps.problems.models import Problemclass Solution (models.Model): problem = models.ForeignKey( Problem, on_delete=models.CASCADE, related_name="solutions" , ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="solutions" , ) title = models.CharField(max_length=100 ) content = models.TextField() language = models.CharField(max_length=20 ,blank=True ) is_public = models.BooleanField(default=True ) created_at = models.DateTimeField(auto_now_add=True ) updated_at = models.DateTimeField(auto_now=True ) class Meta : ordering = ["-created_at" ] def __str__ (self ): return f"{self.problem_id} - {self.title} "
外键problem表示这篇题解属于哪道题
外键author表示题解作者,默认当前用户
接口
serializers.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 from rest_framework import serializersfrom .models import Solutionclass SolutionSerializer (serializers.ModelSerializer): '''题解转换json''' author_username = serializers.CharField(source="author.username" , read_only=True ) class Meta : model = Solution fields = [ "id" , "problem" , "author" , "author_username" , "title" , "content" , "language" , "is_public" , "created_at" , "updated_at" , ] read_only_fields = [ "id" , "problem" , "author" , "author_username" , "created_at" , "updated_at" , ]
这里为了前端展示作者名,需要额外获取,否则author看到的是id
后面read_only_fields表示字段不能由前端提交
views.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 from rest_framework import genericsfrom rest_framework.permissions import IsAuthenticatedOrReadOnlyfrom apps.problems.models import Problemfrom .models import Solutionfrom .serializers import SolutionSerializerclass ProblemSolutionsView (generics.ListCreateAPIView): '''题解列表和创建接口''' serializer_class = SolutionSerializer permission_classes = [IsAuthenticatedOrReadOnly] def get_queryset (self ): problem_id = self .kwargs["problem_id" ] return Solution.objects.select_related("author" , "problem" ).filter ( problem_id=problem_id, is_public=True , ) def perform_create (self, serializer ): problem_id = self .kwargs["problem_id" ] problem = Problem.objects.get(id =problem_id) serializer.save(author=self .request.user, problem=problem) class SolutionDetailView (generics.RetrieveUpdateDestroyAPIView): '''支持GET PATCH DELETE''' serializer_class = SolutionSerializer permission_classes = [IsAuthorOrAdminOrReadOnly] def get_queryset (self ): return Solution.objects.select_related("author" , "problem" ).filter (is_public=True ,)
实现的接口如下:
1 2 GET /api/problems/<problem_id>/solutions/ POST /api/problems/<problem_id>/solutions/
权限为IsAuthenticatedOrReadOnly,即未登录只能查看,已登录才可以发布
后面添加的DetailView,实现了另外两个接口:
1 2 DELETE /api/solutions/solution_id>/ GET /api/problems/<problem_id>/solutions/
支持修改和删除题解
权限
1 2 3 4 5 6 7 8 from rest_framework import permissionsclass IsAuthorOrAdminOrReadOnly (permissions.BasePermission): '''创建权限,该权限只允许本人或者管理员,用于题解的修改和删除''' def has_object_permission (self, request, view, obj ): if request.method in permissions.SAFE_METHODS: return True return obj.author==request.user or request.user.is_staff
添加一个权限组,用于题解,仅作者本人和管理员能够修改和删除
前端
api中的solutions.ts接口如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import http from "./http" ;import type { CreateSolutionRequest , PaginatedResponse , Solution } from "../types/api" ;export function getProblemSolution (problemId : number | string ){ return http.get <PaginatedResponse <Solution >> ( `/problems/${problemId} /solutions/` , ) } export function createProblemSolution (problemId : number |string , data :CreateSolutionRequest , ){ return http.post <Solution > ( `/problems/${problemId} /solutions/` ,data, ) }
添加路由:
1 2 3 4 5 { path : '/problems/:id/solutions' , name : 'problem-solutions' , component : ProblemSolutionsView , }
ProblemSolutionsView.vue这里写主逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 const solutions = ref<Solution []>([]) const solutionLoading = ref (false ) const solutionError = ref ('' ) const solutionTitle = ref ('' ) const solutionContent = ref ('' ) const solutionLanguage = ref ('' ) const solutionSubmitting = ref (false ) ... async function loadSolutions ( ) { if (!problem.value ) return solutionLoading.value = true solutionError.value = '' try { const response = await getProblemSolution (problem.value .id ) solutions.value = response.data .results } catch (error) { solutionError.value = '题解加载失败' } finally { solutionLoading.value = false } } async function handleCreateSolution ( ) { if (!problem.value ) return solutionSubmitting.value = true solutionError.value = '' try { await createProblemSolution (problem.value .id , { title : solutionTitle.value , content : solutionContent.value , language :solutionLanguage.value , is_public :true , }) solutionTitle.value = '' solutionContent.value = '' solutionLanguage.value = '' await loadSolutions () } catch (error) { solutionError.value = '题解发布失败' } finally { solutionLoading.value = false } } async function handleDeleteSolution (solutionId : number ) { const confirmed = window .confirm ('确定删除吗?' ) if (!confirmed) return try { await deleteSolution (solutionId) solutions.value = solutions.value .filter ( (solution ) => solution.id !==solutionId, ) } catch (error) { solutionError.value = '删除题解失败' } } function startEditSolution (solution : Solution ) { editingSolutionId.value = solution.id editTitle.value = solution.title editContent.value = solution.content editLanguage.value = solution.language solutionError.value = '' } function cancelEditSolution ( ) { editingSolutionId.value = null editTitle.value = '' editContent.value = '' editLanguage.value = '' } async function handleUpdateSolution (solutionId : number ) { if (!editTitle.value .trim ()) { solutionError.value = '请输入题解标题' return } if (!editContent.value .trim ()) { solutionError.value = '请输入题解内容' return } try { const response = await updateSolution (solutionId, { title : editTitle.value .trim (), content : editContent.value .trim (), language : editLanguage.value .trim (), is_public : true , }) solutions.value = solutions.value .map ((solution )=> solution.id === solutionId?response.data :solution, ) cancelEditSolution () } catch (error) { } }
loadSolutions是加载题解列表,直接获取当前问题的题解并传入solutions列表,在loadProblem()成功后 调用,因为该方法需要获得problem.value.id
handleCreateSolution是发布题解函数,通过接口createProblemSolution传入数据,并清空表单
handleDeleteSolution是删除题解函数,调用DELETE接口并将页面solutions数组中对应数据清除
handleUpdateSolution是编辑题解函数,调用PATCH接口并将新输进去的内容加入
注意,这里前面有一个start,这里是保存那些不需要修改的其他部分,等到修改结束后,调用cancel把临时保存的清除