- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.9 KiB
Ranking Zones Page — Design Spec
Date: 2026-03-16 Status: Approved
Overview
A new standalone page that displays challenge submissions that students have explicitly nominated for ranking. Each nominated submission is placed into one of four score-based zones (Bayesian weighted score). Students choose which of their submissions enters the ranking; each student may nominate at most one submission per challenge.
1. Scoring: Bayesian Weighted Score (global — replaces existing score)
The existing score field is replaced with a Bayesian weighted score to prevent extreme scores from small numbers of raters distorting rankings. This affects the Submissions page, Leaderboard, My Scores, and the new Ranking page — all use Bayesian score.
Formula
bayesian_score = (C × m + n × raw_score) / (C + n)
raw_score= role-weighted average:(super×0.5 + admin×0.3 + normal×0.2) / nm= global mean ofraw_scoreacross all submissions with at least one rating, excluding the current submissionn= number of raters on this submissionC= 3 (confidence parameter)
When n = 0: both score and raw_score are 0.
Model changes (api/submission/models.py)
Add raw_score field and add Avg import:
from django.db.models import Avg # add to imports
raw_score = models.FloatField(default=0.0, verbose_name="原始加权分")
update_score() replacement
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
# Exclude self to avoid stale DB value skewing the mean
global_mean = (
Submission.objects.filter(raw_score__gt=0)
.exclude(pk=self.pk)
.aggregate(Avg("raw_score"))["raw_score__avg"]
) or self.raw_score # fallback if this is the only ever-rated submission
self.score = (C * global_mean + n * self.raw_score) / (C + n)
self.save(update_fields=["raw_score", "score"])
Downstream note: Profile.recalculate_total_score() (called after every rating via signal) uses best-score-per-challenge. After this change it will use Bayesian score values — leaderboard totals will reflect Bayesian scores. This is the intended global effect.
Migration required: adds raw_score column (default 0.0, no backfill needed for existing rows).
2. Nomination: student opts one submission per challenge into ranking
Model changes (api/submission/models.py)
nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名")
Constraint: at most one nominated submission per (user, task) pair — enforced in the API.
New endpoint (api/submission/api.py)
PUT /api/submission/{submission_id}/nominate
- Login required; only the submission's owner may call it
- Sets
nominated=Trueon this submission - Sets
nominated=Falseon all other submissions by the same user for the same task - Returns
{"nominated": true}
Schema changes (api/submission/schemas.py)
Add nominated: bool = False to SubmissionOut:
nominated: bool = False
No resolver needed — nominated maps directly from the model field.
Frontend type (client/src/utils/type.ts)
Add to SubmissionOut interface:
nominated: boolean
Nomination UI (client/src/pages/Submissions.vue and/or submission result)
- Add a "参与排名" button visible only to the submission's owner (
row.userid === currentUser.id) - Nominated submission: show a 🏅 indicator on the row
- Clicking calls
Submission.nominate(id), then togglesnominatedon the affected rows indata.value(set clicked row totrue, others with sametask_idand same user tofalse)
3. Zones
Only nominated=True submissions appear on the Ranking page. Zones by Bayesian score:
| Zone | Score Condition | Sort Order |
|---|---|---|
| 🏆 精华 | score ≥ 4.5 | score DESC |
| ⭐ 优秀 | 3.5 ≤ score < 4.5 | score DESC |
| 📝 普通 | 0 < score < 3.5 | score DESC |
| ⏳ 待评 | score = 0 | created DESC |
待评 zone uses score__lt=0.001 (not exact float equality) to safely capture unrated submissions.
Minimum non-zero Bayesian score is well above 0.001 (single normal-role rater scoring 1 → raw=0.2 → Bayesian ≈ (3×m+0.2)/4).
4. Ranking Page layout
Split-pane (same as Submissions.vue): left 33% list, right 67% preview.
Left panel:
- Zone tabs with count badges (fetched on mount, not refreshed after scoring)
- Empty zone:
n-emptyplaceholder - Columns:
#·得分·提交者·任务·时间 - Pagination 10/page; resets to page 1 on zone switch
Right panel:
Previewcomponent;@after-scoreupdates onlymy_scoreon the row — zone reassignment waits for full reload (accepted)
5. Backend filter changes (api/submission/)
schemas.py — SubmissionFilter additions
score_min: Optional[float] = None
score_max_exclusive: Optional[float] = None
score_lt_threshold: Optional[float] = None # replaces score_exact_zero; use 0.001 for 待评
nominated: Optional[bool] = None
ordering: Optional[str] = None # "-score" | "score" | "-created"
api.py — list_submissions additions
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)
Existing callers pass none of these params — unaffected.
Zone → API param mapping
| Zone | Params |
|---|---|
| 精华 | task_type=challenge, nominated=true, score_min=4.5, ordering=-score |
| 优秀 | task_type=challenge, nominated=true, score_min=3.5, score_max_exclusive=4.5, ordering=-score |
| 普通 | task_type=challenge, nominated=true, score_min=0.001, score_max_exclusive=3.5, ordering=-score |
| 待评 | task_type=challenge, nominated=true, score_lt_threshold=0.001, ordering=-created |
6. Frontend API changes (client/src/api.ts)
Updated Submission.list signature (adds new params, preserves existing ones):
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
})
New method:
async nominate(id: string) {
const res = await http.put(`/submission/${id}/nominate`)
return res.data
}
7. New file: client/src/pages/Ranking.vue
- Auth-guarded (
meta: { auth: true }) - Zone tab + per-zone page state; default zone: 精华
- 4 parallel count-badge calls on mount
- Selected submission → Preview
8. Router & nav
// router.ts
{ path: '/ranking', component: () => import('./pages/Ranking.vue'), meta: { auth: true } }
Add "排名" link in existing nav.
9. Database migration
Adds raw_score (FloatField, default=0.0) and nominated (BooleanField, default=False) to submission_submission.
Out of Scope
- Per-challenge filtering on Ranking page
- Withdrawing from ranking without re-nominating
- Refreshing zone counts after inline scoring
- Tutorial submissions (fixed
task_type=challengeon all Ranking page requests)