个人中心
需求
- 个人资料显示,包括昵称、头像、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
// 编辑表单状态
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.userMeView修改为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
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
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
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
- 其中python只要调用
- 若所有都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 models
class 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 admin
from .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
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
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]
# 返回相应的json数据
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
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 models
from django.conf import settings
from apps.problems.models import Problem
class 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 serializers
from .models import Solution
class 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 generics
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from apps.problems.models import Problem
from .models import Solution
from .serializers import SolutionSerializer
class 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 permissions
class 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";
// GET接口
export function getProblemSolution(problemId: number| string)
{
return http.get<PaginatedResponse<Solution>>
(
`/problems/${problemId}/solutions/`,
)
}
// POST接口
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.idhandleCreateSolution是发布题解函数,通过接口createProblemSolution传入数据,并清空表单handleDeleteSolution是删除题解函数,调用DELETE接口并将页面solutions数组中对应数据清除handleUpdateSolution是编辑题解函数,调用PATCH接口并将新输进去的内容加入注意,这里前面有一个start,这里是保存那些不需要修改的其他部分,等到修改结束后,调用cancel把临时保存的清除