Files
web/docs/superpowers/plans/2026-03-16-ranking-zones.md
xuyueandClaude Opus 5.5 47d4679984 chore: 合并为单仓库,统一部署流程
- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件
- 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow:
  构建前端上传到 build/,再在服务器上 git pull 并重建容器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:12 +08:00

19 KiB

Ranking Zones Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a zone-based ranking page where students nominate their best submission per challenge, scored by a Bayesian weighted formula, displayed in four quality tiers.

Architecture: Three sequential layers — (1) backend model + Bayesian scoring, (2) nomination API + schema updates, (3) new Ranking frontend page. Each layer is independently testable before moving on.

Tech Stack: Django + django-ninja (backend), Vue 3 + Naive UI + TypeScript (frontend), SQLite/PostgreSQL, Axios


Chunk 1: Backend — Model, Scoring, Migration

Task 1: Add raw_score and nominated fields to Submission model

Files:

  • Modify: api/submission/models.py

  • Step 1: Add imports and new fields

Open api/submission/models.py. At the top, add Avg to the django.db.models import:

from django.db.models import Avg

Add two new fields inside the Submission class, after the flag field:

raw_score = models.FloatField(default=0.0, verbose_name="原始加权分")
nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名")
  • Step 2: Replace update_score() entirely

Replace the existing update_score method with:

def update_score(self):
    ratings = list(self.ratings.select_related("user").all())
    n = len(ratings)

    if n == 0:
        self.raw_score = 0.0
        self.score = 0.0
        self.save(update_fields=["raw_score", "score"])
        return

    weighted_sum = sum(
        r.score * (0.5 if r.user.role == RoleChoices.SUPER
                   else 0.3 if r.user.role == RoleChoices.ADMIN
                   else 0.2)
        for r in ratings
    )
    self.raw_score = weighted_sum / n

    C = 3
    global_mean = (
        Submission.objects.filter(raw_score__gt=0)
        .exclude(pk=self.pk)
        .aggregate(Avg("raw_score"))["raw_score__avg"]
    ) or self.raw_score

    self.score = (C * global_mean + n * self.raw_score) / (C + n)
    self.save(update_fields=["raw_score", "score"])
  • Step 3: Verify the file looks correct
cd api && uv run python -c "from submission.models import Submission; print('OK')"

Expected: OK (no import errors)

  • Step 4: Generate migration
cd api && uv run manage.py makemigrations submission --name "add_raw_score_nominated"

Expected: Migrations for 'submission': api/submission/migrations/0006_add_raw_score_nominated.py

  • Step 5: Apply migration
uv run manage.py migrate

Expected: Applying submission.0006_add_raw_score_nominated... OK

  • Step 6: Smoke-test Bayesian scoring in Django shell
uv run manage.py shell
from submission.models import Submission, Rating
# Pick any submission that has at least one rating
s = Submission.objects.filter(score__gt=0).first()
if s:
    old_score = s.score
    s.update_score()
    print(f"old={old_score:.4f}  new={s.score:.4f}  raw={s.raw_score:.4f}")
else:
    print("No rated submissions yet — will test after first rating")

Expected: prints three float values without errors; score ≈ bayesian-shifted version of raw_score.

  • Step 7: Commit
cd api
git add submission/models.py submission/migrations/0006_add_raw_score_nominated.py
git commit -m "feat: add raw_score/nominated fields and Bayesian update_score"

Task 2: Update schemas — SubmissionOut and SubmissionFilter

Files:

  • Modify: api/submission/schemas.py

  • Step 1: Add nominated to SubmissionOut

In SubmissionOut, add after flag:

nominated: bool = False

No resolver is needed — the field maps directly from the model.

Also add "nominated": submission.nominated to the static get() method dict (around line 69), after "flag":

"flag": submission.flag,
"nominated": submission.nominated,  # ← add this line
  • Step 2: Add new filter fields to SubmissionFilter

Replace the existing SubmissionFilter class with:

class SubmissionFilter(Schema):
    task_id: Optional[int] = None
    task_type: Optional[Literal["tutorial", "challenge"]] = None
    username: Optional[str] = None
    flag: Optional[Literal["red", "blue", "green", "yellow"]] = None
    score_min: Optional[float] = None
    score_max_exclusive: Optional[float] = None
    score_lt_threshold: Optional[float] = None
    nominated: Optional[bool] = None
    ordering: Optional[str] = None
  • Step 3: Verify schema imports

Check the top of schemas.py — Optional and Literal are already imported. No new imports needed.

  • Step 4: Quick check
uv run manage.py check

Expected: System check identified no issues (0 silenced).

  • Step 5: Commit
git add submission/schemas.py
git commit -m "feat: add nominated to SubmissionOut, add score/ordering filters to SubmissionFilter"

Task 3: Add filter logic to list_submissions + new nominate endpoint

Files:

  • Modify: api/submission/api.py

  • Step 1: Apply new filters in list_submissions

In list_submissions, after the existing if filters.flag: block and before user_rating_subquery, add:

if filters.nominated is not None:
    submissions = submissions.filter(nominated=filters.nominated)
if filters.score_lt_threshold is not None:
    submissions = submissions.filter(score__lt=filters.score_lt_threshold)
else:
    if filters.score_min is not None:
        submissions = submissions.filter(score__gte=filters.score_min)
    if filters.score_max_exclusive is not None:
        submissions = submissions.filter(score__lt=filters.score_max_exclusive)
if filters.ordering in ("-score", "score", "-created"):
    submissions = submissions.order_by(filters.ordering)
  • Step 2: Add the nominate endpoint

Append to api/submission/api.py:

@router.put("/{submission_id}/nominate")
@login_required
def nominate_submission(request, submission_id: UUID):
    """
    学生将某条提交标记为"参与排名"。
    同一用户同一题目只能有一条参与排名,旧的自动取消。
    """
    submission = get_object_or_404(Submission, id=submission_id)

    if submission.user != request.user:
        raise HttpError(403, "只能提名自己的提交")

    # Clear any existing nomination for same user + same task
    Submission.objects.filter(
        user=request.user,
        task=submission.task,
        nominated=True,
    ).exclude(pk=submission.pk).update(nominated=False)

    submission.nominated = True
    submission.save(update_fields=["nominated"])

    return {"nominated": True}
  • Step 3: Check the server starts without errors
uv run manage.py check

Expected: System check identified no issues (0 silenced).

  • Step 4: Manual API test (optional — requires dev server)

Start the server: uv run manage.py runserver

Test the nominate endpoint with curl (replace <uuid> and cookie):

curl -X PUT http://localhost:8000/api/submission/<uuid>/nominate \
  -H "Cookie: sessionid=<your-session>"

Expected: {"nominated": true}

  • Step 5: Commit
git add submission/api.py
git commit -m "feat: add score/nominated filters to list endpoint and PUT nominate endpoint"

Chunk 2: Frontend — Types and API Client

Task 4: Update TypeScript types

Files:

  • Modify: client/src/utils/type.ts

  • Step 1: Add nominated to SubmissionOut interface

Find the SubmissionOut interface (around line 59) and add nominated:

export interface SubmissionOut {
  id: string
  userid: number
  username: string
  task_display: number
  task_type: TASK_TYPE
  task_title: string
  score: number
  my_score: number
  conversation_id?: string
  flag?: FlagType
  nominated: boolean   // ← add this
  created: Date
  modified: Date
}
  • Step 2: Commit
cd client
git add src/utils/type.ts
git commit -m "feat: add nominated field to SubmissionOut interface"

Task 5: Update api.ts — Submission.list signature and nominate method

Files:

  • Modify: client/src/api.ts

  • Step 1: Expand Submission.list signature

Find the existing list method in the Submission object:

async list(query: { page: number }) {

Replace with:

async list(query: {
  page: number
  username?: string
  flag?: string | null
  task_type?: string
  score_min?: number
  score_max_exclusive?: number
  score_lt_threshold?: number
  nominated?: boolean
  ordering?: string
}) {
  • Step 2: Add nominate method

After updateFlag, add:

async nominate(id: string) {
  const res = await http.put(`/submission/${id}/nominate`)
  return res.data as { nominated: boolean }
},
  • Step 3: Verify TypeScript compiles
npm run build 2>&1 | grep -E "error|Error" | head -20

Expected: no TypeScript errors related to Submission.list or SubmissionOut.

  • Step 4: Commit
git add src/api.ts
git commit -m "feat: expand Submission.list params and add Submission.nominate"

Chunk 3: Frontend — Nomination UI + Ranking Page

Task 6: Add nomination button to Submissions.vue

Files:

  • Modify: client/src/pages/Submissions.vue

  • Step 1: Update the user store import in Submissions.vue

The file currently imports only { roleAdmin, roleSuper } from "../store/user" (line 145). Add user to that import:

import { roleAdmin, roleSuper, user } from "../store/user"

(user has a username property which is used to identify the current user's rows, since the user store has no id field.)

  • Step 2: Add nomination column to columns array

In the columns array (around line 224), add a new column after the flag column:

{
  title: "排名",
  key: "nominated",
  width: 60,
  render: (row) => {
    // Only show nomination button for the current user's own submissions
    if (row.username !== user.username) {
      return row.nominated
        ? h("span", { style: { color: "#f0a020" } }, "🏅")
        : null
    }
    return h(
      NButton,
      {
        text: true,
        title: row.nominated ? "已参与排名(点击可重新提名)" : "参与排名",
        onClick: (e: Event) => {
          e.stopPropagation()
          handleNominate(row)
        },
      },
      () => (row.nominated ? "🏅" : "☆"),
    )
  },
},
  • Step 3: Add handleNominate function

After the afterScore function, add:

async function handleNominate(row: SubmissionOut) {
  await Submission.nominate(row.id)
  // Update local state: clear other nominations for same task by same user, set this one
  data.value = data.value.map((d) => {
    if (d.username === user.username && d.task_display === row.task_display) {
      d.nominated = d.id === row.id
    }
    return d
  })
}
  • Step 4: Verify the page renders without errors

Start the frontend dev server: npm run start

Open Submissions page. Verify:

  • 排名 column appears

  • Your own submissions show ☆ / 🏅 button

  • Other users' submissions show 🏅 if nominated, nothing if not

  • Clicking ☆ calls the API and changes to 🏅 (and clears 🏅 from others in same task)

  • Step 5: Commit

git add src/pages/Submissions.vue
git commit -m "feat: add nomination column to Submissions page"

Task 7: Create Ranking.vue

Files:

  • Create: client/src/pages/Ranking.vue

  • Step 1: Create the file

Create client/src/pages/Ranking.vue with the following content:

<template>
  <n-split class="container" direction="horizontal" :default-size="0.333" :min="0.2" :max="0.8">
    <template #1>
      <n-flex vertical style="height: 100%; padding-right: 10px">
        <n-flex justify="space-between" align="center">
          <n-button secondary @click="$router.back()">返回</n-button>
          <span style="font-weight: bold; font-size: 16px">排名榜</span>
          <div style="width: 60px" />
        </n-flex>

        <!-- Zone tabs -->
        <n-tabs v-model:value="activeZone" type="line" animated @update:value="onZoneChange">
          <n-tab v-for="zone in ZONES" :key="zone.key" :name="zone.key">
            <n-flex align="center" :style="{ gap: '4px' }">
              <span>{{ zone.label }}</span>
              <n-badge
                v-if="counts[zone.key] !== undefined"
                :value="counts[zone.key]"
                :max="999"
                :show-zero="true"
                style="margin-left: 4px"
              />
            </n-flex>
          </n-tab>
        </n-tabs>

        <!-- Ranked table -->
        <n-empty
          v-if="!loading && data.length === 0"
          description="该区暂无提交"
          style="margin: auto"
        />
        <n-data-table
          v-else
          striped
          :columns="columns"
          :data="data"
          :loading="loading"
          :row-props="rowProps"
          :row-class-name="rowClassName"
        />

        <n-pagination
          v-model:page="page"
          :page-size="PAGE_SIZE"
          :item-count="counts[activeZone] ?? 0"
          simple
          style="align-self: flex-end"
        />
      </n-flex>
    </template>
    <template #2>
      <div style="height: 100%; padding-left: 10px">
        <Preview
          v-if="selectedSubmission.id"
          :html="selectedSubmission.html"
          :css="selectedSubmission.css"
          :js="selectedSubmission.js"
          :submission-id="selectedSubmission.id"
          @after-score="afterScore"
        />
      </div>
    </template>
  </n-split>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, h } from "vue"
import type { DataTableColumn } from "naive-ui"
import { Submission } from "../api"
import type { SubmissionOut } from "../utils/type"
import { parseTime } from "../utils/helper"
import Preview from "../components/Preview.vue"
import { submission as submissionStore } from "../store/submission"

const PAGE_SIZE = 10

interface Zone {
  key: string
  label: string
  params: Record<string, unknown>
}

const ZONES: Zone[] = [
  {
    key: "top",
    label: "🏆 精华",
    params: { score_min: 4.5, ordering: "-score" },
  },
  {
    key: "good",
    label: "⭐ 优秀",
    params: { score_min: 3.5, score_max_exclusive: 4.5, ordering: "-score" },
  },
  {
    key: "normal",
    label: "📝 普通",
    params: { score_min: 0.001, score_max_exclusive: 3.5, ordering: "-score" },
  },
  {
    key: "unrated",
    label: "⏳ 待评",
    params: { score_lt_threshold: 0.001, ordering: "-created" },
  },
]

const activeZone = ref("top")
const page = ref(1)
const data = ref<SubmissionOut[]>([])
const loading = ref(false)
const counts = reactive<Record<string, number>>({})

const selectedSubmission = computed(() => submissionStore.value)

const columns: DataTableColumn<SubmissionOut>[] = [
  {
    title: "#",
    key: "rank",
    width: 45,
    render: (_, index) => (page.value - 1) * PAGE_SIZE + index + 1,
  },
  {
    title: "得分",
    key: "score",
    width: 65,
    render: (row) =>
      row.score > 0
        ? h("span", { style: { fontWeight: "bold" } }, row.score.toFixed(2))
        : h("span", { style: { color: "#999" } }, "—"),
  },
  { title: "提交者", key: "username", width: 80, render: (row) => row.username },
  { title: "任务", key: "task_title", render: (row) => row.task_title },
  {
    title: "时间",
    key: "created",
    width: 110,
    render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
  },
]

function rowProps(row: SubmissionOut) {
  return {
    style: { cursor: "pointer" },
    onClick: () => loadSubmission(row.id),
  }
}

function rowClassName(row: SubmissionOut) {
  return submissionStore.value.id === row.id ? "row-active" : ""
}

async function loadSubmission(id: string) {
  submissionStore.value = await Submission.get(id)
}

function afterScore() {
  data.value = data.value.map((d) => {
    if (d.id === submissionStore.value.id) {
      d.my_score = submissionStore.value.my_score
    }
    return d
  })
}

function currentZone(): Zone {
  return ZONES.find((z) => z.key === activeZone.value)!
}

async function fetchPage() {
  loading.value = true
  try {
    const zone = currentZone()
    const res = await Submission.list({
      page: page.value,
      task_type: "challenge",
      nominated: true,
      ...zone.params,
    } as Parameters<typeof Submission.list>[0])
    data.value = res.items
    counts[zone.key] = res.count
  } finally {
    loading.value = false
  }
}

async function fetchAllCounts() {
  await Promise.all(
    ZONES.map(async (zone) => {
      const res = await Submission.list({
        page: 1,
        task_type: "challenge",
        nominated: true,
        ...zone.params,
      } as Parameters<typeof Submission.list>[0])
      counts[zone.key] = res.count
    }),
  )
}

function onZoneChange() {
  page.value = 1
  fetchPage()
}

watch(page, fetchPage)

onMounted(async () => {
  await fetchAllCounts()
  await fetchPage()
})
</script>

<style scoped>
.container {
  padding: 10px;
  box-sizing: border-box;
  height: calc(100% - 43px);
  width: 100%;
}

:deep(.row-active td) {
  background-color: rgba(24, 160, 80, 0.1) !important;
}
</style>
  • Step 2: Verify no TypeScript errors
npm run build 2>&1 | grep -E "error TS|Error" | head -20

Expected: no TypeScript errors in Ranking.vue.

  • Step 3: Commit
git add src/pages/Ranking.vue
git commit -m "feat: add Ranking page with four-zone display"

Task 8: Register route and add nav entry

Files:

  • Modify: client/src/router.ts

  • Modify: client/src/components/Toolbar.vue

  • Step 1: Add route to router.ts

After the leaderboard route entry, add:

{
  path: "/ranking",
  name: "ranking",
  component: () => import("./pages/Ranking.vue"),
  meta: { auth: true },
},
  • Step 2: Add "排名" menu item to Toolbar.vue

In the menu computed array, after the "我的提交" entry, add:

{
  label: "排名榜",
  key: "ranking",
  icon: () =>
    h(Icon, {
      icon: "streamline-emojis:trophy",
    }),
},
  • Step 3: Handle the new menu key in clickMenu

In the clickMenu switch, after the submissions case, add:

case "ranking":
  router.push({ name: "ranking" })
  break
  • Step 4: End-to-end verification

Start both servers (uv run manage.py runserver + npm run start).

Checklist:

  • Log in, open user menu → "排名榜" appears

  • Clicking "排名榜" navigates to /ranking

  • All four zone tabs render with count badges

  • 待评 tab shows submissions with score = 0

  • Click a row → right panel shows the preview

  • On Submissions page, own submissions show ☆ / 🏅 in "排名" column

  • Clicking ☆ calls nominate, row shows 🏅, previous nomination clears

  • After nominating, go to Ranking page → submission appears in correct zone

  • Step 5: Commit

git add src/router.ts src/components/Toolbar.vue
git commit -m "feat: register /ranking route and add nav entry"