红魔咖啡馆

头发越掉越多,头发越掉越少

0%

【KouOJ】添加功能

个人中心

需求

  • 个人资料显示,包括昵称、头像、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.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
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

运行流程:

  1. 创建临时目录
  2. 写入 main.c 或 main.cpp
  3. 调用 Docker 编译
  4. 如果编译失败,直接返回编译错误
  5. 如果编译成功,对每个测试点运行 ./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
  • 若所有都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.id

  • handleCreateSolution是发布题解函数,通过接口createProblemSolution传入数据,并清空表单

  • handleDeleteSolution是删除题解函数,调用DELETE接口并将页面solutions数组中对应数据清除

  • handleUpdateSolution是编辑题解函数,调用PATCH接口并将新输进去的内容加入

    注意,这里前面有一个start,这里是保存那些不需要修改的其他部分,等到修改结束后,调用cancel把临时保存的清除