Files
web/docs/superpowers/plans/2026-04-29-showcase.md
T
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

44 KiB

Showcase Feature Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a showcase page where logged-in students can browse award-winning submissions grouped by award category, with optional prompt-chain viewing.

Architecture: Two new Django models (Award, SubmissionAward) stored in submission/, three new API endpoints under /api/submission/showcase/, two new Vue pages (Showcase.vue, ShowcaseDetail.vue) plus a nav entry in TaskPanel.vue.

Tech Stack: Django + django-ninja, Vue 3 + Naive UI, TimeStampedModel from django_extensions


File Map

Create:

  • api/submission/tests_showcase.py — all showcase backend tests
  • client/src/pages/Showcase.vue — award grid page
  • client/src/pages/ShowcaseDetail.vue — submission detail + prompt chain

Modify:

  • api/submission/models.py — add Award, SubmissionAward, ItemOrdering
  • api/submission/admin.py — register Award, SubmissionAward
  • api/submission/schemas.py — add ShowcaseItemOut, AwardOut, ShowcaseDetailOut, PromptRoundOut
  • api/submission/api.py — add showcase endpoints
  • client/src/utils/type.ts — add showcase types
  • client/src/api.ts — add Showcase object
  • client/src/router.ts — add /showcase and /showcase/:id routes
  • client/src/components/task/TaskPanel.vue — add nav button

Task 1: Add Award and SubmissionAward models

Files:

  • Modify: api/submission/models.py

  • Step 1: Add models

Append to api/submission/models.py after the Rating model and its signal:

class ItemOrdering(models.TextChoices):
    MANUAL = "manual", "手动排序"
    AWARDED_AT = "awarded_at", "授奖时间倒序"
    SCORE = "score", "评分倒序"
    VIEW_COUNT = "view_count", "浏览量倒序"


class Award(TimeStampedModel):
    name = models.CharField(max_length=100, unique=True, verbose_name="奖项名称")
    description = models.TextField(blank=True, default="", verbose_name="奖项简介")
    sort_order = models.IntegerField(default=0, db_index=True, verbose_name="排序值")
    is_active = models.BooleanField(default=True, verbose_name="是否启用")
    item_ordering = models.CharField(
        max_length=20,
        choices=ItemOrdering.choices,
        default=ItemOrdering.MANUAL,
        verbose_name="作品排序方式",
    )

    class Meta:
        ordering = ("sort_order",)

    def __str__(self):
        return self.name


class SubmissionAward(TimeStampedModel):
    submission = models.ForeignKey(
        Submission, on_delete=models.CASCADE, related_name="awards"
    )
    award = models.ForeignKey(
        Award, on_delete=models.CASCADE, related_name="submission_awards"
    )
    sort_order = models.IntegerField(default=0, db_index=True, verbose_name="手动排序值")
    awarded_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="授奖时间")

    class Meta:
        unique_together = ("submission", "award")
        ordering = ("sort_order",)

    def __str__(self):
        return f"{self.award.name} — {self.submission}"
  • Step 2: Create migration
cd api && uv run manage.py makemigrations submission --name add_award_submissionaward

Expected: creates api/submission/migrations/0011_add_award_submissionaward.py

  • Step 3: Apply migration
cd api && uv run manage.py migrate

Expected: OK with no errors

  • Step 4: Commit
git add api/submission/models.py api/submission/migrations/0011_add_award_submissionaward.py
git commit -m "feat: add Award and SubmissionAward models"

Task 2: Register Award and SubmissionAward in Django admin

Files:

  • Modify: api/submission/admin.py

  • Step 1: Write failing test

Create api/submission/tests_showcase.py:

from django.test import TestCase
from django.contrib.auth import get_user_model
from task.models import Task
from submission.models import Submission, Award, SubmissionAward

User = get_user_model()


def _make_user(username="student1", role="normal"):
    return User.objects.create_user(username=username, password="pw", role=role)


def _make_task(display=1):
    return Task.objects.create(
        title=f"Task {display}", task_type="challenge", display=display, content=""
    )


def _make_submission(user, task, score=0.0):
    return Submission.objects.create(
        user=user, task=task, html="<p>hi</p>", css="", js="", score=score
    )


def _make_award(name="最佳设计", sort_order=0, is_active=True, item_ordering="manual"):
    return Award.objects.create(
        name=name,
        sort_order=sort_order,
        is_active=is_active,
        item_ordering=item_ordering,
    )


class AwardModelTest(TestCase):
    def test_unique_submission_award(self):
        """Same submission + award combo cannot be created twice"""
        user = _make_user()
        task = _make_task()
        sub = _make_submission(user, task)
        award = _make_award()
        SubmissionAward.objects.create(submission=sub, award=award)
        from django.db import IntegrityError
        with self.assertRaises(IntegrityError):
            SubmissionAward.objects.create(submission=sub, award=award)

    def test_submission_can_have_multiple_awards(self):
        user = _make_user()
        task = _make_task()
        sub = _make_submission(user, task)
        a1 = _make_award("奖1", sort_order=0)
        a2 = _make_award("奖2", sort_order=1)
        SubmissionAward.objects.create(submission=sub, award=a1)
        SubmissionAward.objects.create(submission=sub, award=a2)
        self.assertEqual(sub.awards.count(), 2)

    def test_award_can_have_multiple_submissions(self):
        user = _make_user()
        task = _make_task()
        sub1 = _make_submission(user, task, score=3.0)
        task2 = _make_task(display=2)
        sub2 = _make_submission(user, task2, score=4.0)
        award = _make_award()
        SubmissionAward.objects.create(submission=sub1, award=award)
        SubmissionAward.objects.create(submission=sub2, award=award)
        self.assertEqual(award.submission_awards.count(), 2)
  • Step 2: Run test to verify it passes (models already created)
cd api && uv run manage.py test submission.tests_showcase.AwardModelTest -v 2

Expected: 3 tests PASS

  • Step 3: Implement admin registration

Replace contents of api/submission/admin.py:

from django.contrib import admin
from .models import Award, Submission, SubmissionAward


@admin.register(Award)
class AwardAdmin(admin.ModelAdmin):
    list_display = ("name", "sort_order", "is_active", "item_ordering", "created")
    list_filter = ("is_active", "item_ordering")
    search_fields = ("name",)
    ordering = ("sort_order",)


@admin.register(SubmissionAward)
class SubmissionAwardAdmin(admin.ModelAdmin):
    list_display = (
        "award_name",
        "submission_username",
        "submission_task_title",
        "submission_score",
        "submission_view_count",
        "sort_order",
        "awarded_at",
    )
    list_filter = ("award", "submission__task", "submission__user__classname")
    search_fields = (
        "award__name",
        "submission__user__username",
        "submission__task__title",
    )
    raw_id_fields = ("submission",)

    @admin.display(description="奖项")
    def award_name(self, obj):
        return obj.award.name

    @admin.display(description="提交作者")
    def submission_username(self, obj):
        return obj.submission.user.username

    @admin.display(description="挑战标题")
    def submission_task_title(self, obj):
        return obj.submission.task.title

    @admin.display(description="评分")
    def submission_score(self, obj):
        return obj.submission.score

    @admin.display(description="浏览量")
    def submission_view_count(self, obj):
        return obj.submission.view_count
  • Step 4: Commit
git add api/submission/admin.py api/submission/tests_showcase.py
git commit -m "feat: register Award and SubmissionAward in Django admin"

Task 3: Add showcase schemas

Files:

  • Modify: api/submission/schemas.py

  • Step 1: Add schemas

Append to the bottom of api/submission/schemas.py:

class ShowcaseItemOut(Schema):
    submission_id: UUID
    username: str
    task_title: str
    task_display: int
    score: float
    view_count: int
    html: Optional[str] = None
    css: Optional[str] = None
    js: Optional[str] = None
    has_prompt_chain: bool


class AwardOut(Schema):
    id: int
    name: str
    description: str
    item_ordering: str
    items: list[ShowcaseItemOut]


class ShowcaseDetailOut(Schema):
    submission_id: UUID
    username: str
    task_title: str
    task_display: int
    score: float
    view_count: int
    html: Optional[str] = None
    css: Optional[str] = None
    js: Optional[str] = None
    awards: list[str]
    has_prompt_chain: bool


class PromptRoundOut(Schema):
    question: str
    source: str
    prompt_level: Optional[int] = None
    html: Optional[str] = None
    css: Optional[str] = None
    js: Optional[str] = None
  • Step 2: Verify import is not broken
cd api && uv run manage.py check

Expected: System check identified no issues.

  • Step 3: Commit
git add api/submission/schemas.py
git commit -m "feat: add showcase schemas"

Task 4: Implement showcase list API endpoint with tests

Files:

  • Modify: api/submission/api.py

  • Modify: api/submission/tests_showcase.py

  • Step 1: Write failing tests

Append to api/submission/tests_showcase.py:

class ShowcaseListTest(TestCase):
    def setUp(self):
        self.user = _make_user("student1")
        self.task = _make_task()

    def test_unauthenticated_returns_401(self):
        resp = self.client.get("/api/submission/showcase/")
        self.assertEqual(resp.status_code, 401)

    def test_authenticated_returns_200(self):
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        self.assertEqual(resp.status_code, 200)

    def test_inactive_award_not_returned(self):
        _make_award("停用奖", is_active=False)
        sub = _make_submission(self.user, self.task)
        award = Award.objects.get(name="停用奖")
        SubmissionAward.objects.create(submission=sub, award=award)
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        data = resp.json()
        self.assertEqual(len(data), 0)

    def test_award_with_no_items_not_returned(self):
        _make_award("空奖项")
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        data = resp.json()
        self.assertEqual(len(data), 0)

    def test_active_award_with_items_returned(self):
        award = _make_award("最佳设计")
        sub = _make_submission(self.user, self.task)
        SubmissionAward.objects.create(submission=sub, award=award)
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        data = resp.json()
        self.assertEqual(len(data), 1)
        self.assertEqual(data[0]["name"], "最佳设计")
        self.assertEqual(len(data[0]["items"]), 1)
        item = data[0]["items"][0]
        self.assertEqual(item["username"], "student1")
        self.assertEqual(item["has_prompt_chain"], False)

    def test_manual_ordering_uses_sort_order(self):
        award = _make_award("奖", item_ordering="manual")
        sub1 = _make_submission(self.user, self.task)
        task2 = _make_task(display=2)
        sub2 = _make_submission(self.user, task2)
        SubmissionAward.objects.create(submission=sub1, award=award, sort_order=2)
        SubmissionAward.objects.create(submission=sub2, award=award, sort_order=1)
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        items = resp.json()[0]["items"]
        self.assertEqual(items[0]["task_display"], task2.display)
        self.assertEqual(items[1]["task_display"], self.task.display)

    def test_score_ordering(self):
        award = _make_award("奖", item_ordering="score")
        sub1 = _make_submission(self.user, self.task, score=2.0)
        task2 = _make_task(display=2)
        sub2 = _make_submission(self.user, task2, score=4.0)
        SubmissionAward.objects.create(submission=sub1, award=award)
        SubmissionAward.objects.create(submission=sub2, award=award)
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        items = resp.json()[0]["items"]
        self.assertGreater(items[0]["score"], items[1]["score"])

    def test_view_count_ordering(self):
        award = _make_award("奖", item_ordering="view_count")
        sub1 = _make_submission(self.user, self.task)
        sub1.view_count = 5
        sub1.save(update_fields=["view_count"])
        task2 = _make_task(display=2)
        sub2 = _make_submission(self.user, task2)
        sub2.view_count = 20
        sub2.save(update_fields=["view_count"])
        SubmissionAward.objects.create(submission=sub1, award=award)
        SubmissionAward.objects.create(submission=sub2, award=award)
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        items = resp.json()[0]["items"]
        self.assertGreater(items[0]["view_count"], items[1]["view_count"])

    def test_has_prompt_chain_true_when_source_message_exists(self):
        from prompt.models import Conversation, Message
        award = _make_award("奖")
        sub = _make_submission(self.user, self.task)
        conv = Conversation.objects.create(user=self.user, task=self.task)
        Message.objects.create(conversation=conv, role="user", content="做个按钮")
        Message.objects.create(
            conversation=conv, role="assistant", content="好的",
            code_html="<button>OK</button>", code_css="", code_js="",
            submission=sub,
        )
        SubmissionAward.objects.create(submission=sub, award=award)
        self.client.force_login(self.user)
        resp = self.client.get("/api/submission/showcase/")
        item = resp.json()[0]["items"][0]
        self.assertTrue(item["has_prompt_chain"])
  • Step 2: Run tests to see them fail
cd api && uv run manage.py test submission.tests_showcase.ShowcaseListTest -v 2

Expected: FAIL — 404 response since endpoints don't exist yet

  • Step 3: Implement showcase list endpoint

In api/submission/api.py, add these imports at the top of the file (after existing imports):

from django.db.models import Exists
from .models import Award, SubmissionAward, ItemOrdering
from .schemas import AwardOut, ShowcaseDetailOut, ShowcaseItemOut, PromptRoundOut

Then append this endpoint to api/submission/api.py (before the final get_submission endpoint to keep URL specificity correct — add right before @router.get("/{submission_id}", ...)):

@router.get("/showcase/", response=List[AwardOut])
@login_required
def list_showcase(request):
    ordering_map = {
        ItemOrdering.MANUAL: "sort_order",
        ItemOrdering.AWARDED_AT: "-awarded_at",
        ItemOrdering.SCORE: "-submission__score",
        ItemOrdering.VIEW_COUNT: "-submission__view_count",
    }
    awards = Award.objects.filter(is_active=True).order_by("sort_order")
    result = []
    for award in awards:
        order_field = ordering_map.get(award.item_ordering, "sort_order")
        items_qs = (
            SubmissionAward.objects.filter(award=award)
            .select_related("submission", "submission__user", "submission__task")
            .annotate(
                has_prompt_chain=Exists(
                    Message.objects.filter(submission_id=OuterRef("submission_id"))
                )
            )
            .order_by(order_field)
        )
        items = list(items_qs)
        if not items:
            continue
        result.append({
            "id": award.id,
            "name": award.name,
            "description": award.description,
            "item_ordering": award.item_ordering,
            "items": [
                {
                    "submission_id": sa.submission_id,
                    "username": sa.submission.user.username,
                    "task_title": sa.submission.task.title,
                    "task_display": sa.submission.task.display,
                    "score": sa.submission.score,
                    "view_count": sa.submission.view_count,
                    "html": sa.submission.html,
                    "css": sa.submission.css,
                    "js": sa.submission.js,
                    "has_prompt_chain": sa.has_prompt_chain,
                }
                for sa in items
            ],
        })
    return result
  • Step 4: Run tests to verify they pass
cd api && uv run manage.py test submission.tests_showcase.ShowcaseListTest -v 2

Expected: 8 tests PASS

  • Step 5: Commit
git add api/submission/api.py api/submission/schemas.py api/submission/tests_showcase.py
git commit -m "feat: add showcase list API endpoint"

Task 5: Implement showcase detail API endpoint with tests

Files:

  • Modify: api/submission/api.py

  • Modify: api/submission/tests_showcase.py

  • Step 1: Write failing tests

Append to api/submission/tests_showcase.py:

class ShowcaseDetailTest(TestCase):
    def setUp(self):
        self.user = _make_user("student1")
        self.task = _make_task()
        self.award = _make_award("最佳设计")
        self.sub = _make_submission(self.user, self.task, score=4.5)
        self.sub.view_count = 10
        self.sub.save(update_fields=["view_count"])
        SubmissionAward.objects.create(submission=self.sub, award=self.award)

    def test_unauthenticated_returns_401(self):
        resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/")
        self.assertEqual(resp.status_code, 401)

    def test_awarded_submission_accessible(self):
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/")
        self.assertEqual(resp.status_code, 200)
        data = resp.json()
        self.assertEqual(data["username"], "student1")
        self.assertEqual(data["score"], 4.5)
        self.assertEqual(data["view_count"], 10)
        self.assertIn("最佳设计", data["awards"])
        self.assertFalse(data["has_prompt_chain"])

    def test_non_awarded_submission_returns_404(self):
        other_sub = _make_submission(self.user, self.task)
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{other_sub.id}/")
        self.assertEqual(resp.status_code, 404)

    def test_submission_shows_all_its_awards(self):
        award2 = _make_award("最佳游戏", sort_order=1)
        SubmissionAward.objects.create(submission=self.sub, award=award2)
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/")
        data = resp.json()
        self.assertIn("最佳设计", data["awards"])
        self.assertIn("最佳游戏", data["awards"])
  • Step 2: Run tests to see them fail
cd api && uv run manage.py test submission.tests_showcase.ShowcaseDetailTest -v 2

Expected: FAIL — 404 or 405 since endpoint doesn't exist

  • Step 3: Implement showcase detail endpoint

Add this endpoint to api/submission/api.py, right after list_showcase:

@router.get("/showcase/{submission_id}/", response=ShowcaseDetailOut)
@login_required
def get_showcase_detail(request, submission_id: UUID):
    if not SubmissionAward.objects.filter(submission_id=submission_id).exists():
        raise HttpError(404, "作品不存在或未授奖")
    sub = get_object_or_404(
        Submission.objects.select_related("user", "task"),
        id=submission_id,
    )
    has_chain = Message.objects.filter(submission=sub).exists()
    award_names = list(
        SubmissionAward.objects.filter(submission=sub)
        .select_related("award")
        .values_list("award__name", flat=True)
    )
    return {
        "submission_id": sub.id,
        "username": sub.user.username,
        "task_title": sub.task.title,
        "task_display": sub.task.display,
        "score": sub.score,
        "view_count": sub.view_count,
        "html": sub.html,
        "css": sub.css,
        "js": sub.js,
        "awards": award_names,
        "has_prompt_chain": has_chain,
    }
  • Step 4: Run tests to verify they pass
cd api && uv run manage.py test submission.tests_showcase.ShowcaseDetailTest -v 2

Expected: 4 tests PASS

  • Step 5: Commit
git add api/submission/api.py api/submission/tests_showcase.py
git commit -m "feat: add showcase detail API endpoint"

Task 6: Implement showcase prompt-chain API endpoint with tests

Files:

  • Modify: api/submission/api.py

  • Modify: api/submission/tests_showcase.py

  • Step 1: Write failing tests

Append to api/submission/tests_showcase.py:

class ShowcasePromptChainTest(TestCase):
    def setUp(self):
        from prompt.models import Conversation, Message as Msg
        self.user = _make_user("student1")
        self.task = _make_task()
        self.award = _make_award("最佳设计")
        self.sub = _make_submission(self.user, self.task)
        SubmissionAward.objects.create(submission=self.sub, award=self.award)

        self.conv = Conversation.objects.create(user=self.user, task=self.task)
        self.user_msg = Msg.objects.create(
            conversation=self.conv, role="user", content="做个按钮",
            source="conversation", prompt_level=3,
        )
        self.asst_msg = Msg.objects.create(
            conversation=self.conv, role="assistant", content="好的",
            code_html="<button>OK</button>", code_css="button{color:red}",
            code_js="console.log(1)", submission=self.sub,
        )

    def test_unauthenticated_returns_401(self):
        resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/prompt-chain/")
        self.assertEqual(resp.status_code, 401)

    def test_no_source_message_returns_404(self):
        other_sub = _make_submission(self.user, self.task)
        SubmissionAward.objects.create(submission=other_sub, award=self.award)
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{other_sub.id}/prompt-chain/")
        self.assertEqual(resp.status_code, 404)

    def test_non_awarded_submission_returns_404(self):
        from prompt.models import Conversation, Message as Msg
        other_sub = _make_submission(self.user, self.task)
        conv = Conversation.objects.create(user=self.user, task=self.task)
        Msg.objects.create(
            conversation=conv, role="assistant", content="x",
            submission=other_sub,
        )
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{other_sub.id}/prompt-chain/")
        self.assertEqual(resp.status_code, 404)

    def test_returns_prompt_rounds(self):
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/prompt-chain/")
        self.assertEqual(resp.status_code, 200)
        rounds = resp.json()
        self.assertEqual(len(rounds), 1)
        r = rounds[0]
        self.assertEqual(r["question"], "做个按钮")
        self.assertEqual(r["source"], "conversation")
        self.assertEqual(r["prompt_level"], 3)
        self.assertEqual(r["html"], "<button>OK</button>")
        self.assertEqual(r["css"], "button{color:red}")
        self.assertEqual(r["js"], "console.log(1)")

    def test_multiple_rounds(self):
        from prompt.models import Message as Msg
        Msg.objects.create(
            conversation=self.conv, role="user", content="再加个标题",
            source="manual",
        )
        Msg.objects.create(
            conversation=self.conv, role="assistant", content="好",
            code_html="<h1>标题</h1>", code_css="", code_js="",
        )
        self.client.force_login(self.user)
        resp = self.client.get(f"/api/submission/showcase/{self.sub.id}/prompt-chain/")
        rounds = resp.json()
        self.assertEqual(len(rounds), 2)
        self.assertEqual(rounds[1]["question"], "再加个标题")
        self.assertEqual(rounds[1]["source"], "manual")
  • Step 2: Run tests to see them fail
cd api && uv run manage.py test submission.tests_showcase.ShowcasePromptChainTest -v 2

Expected: FAIL — endpoint doesn't exist

  • Step 3: Implement prompt-chain endpoint

Add to api/submission/api.py, after get_showcase_detail:

@router.get("/showcase/{submission_id}/prompt-chain/", response=List[PromptRoundOut])
@login_required
def get_showcase_prompt_chain(request, submission_id: UUID):
    if not SubmissionAward.objects.filter(submission_id=submission_id).exists():
        raise HttpError(404, "作品不存在或未授奖")
    sub = get_object_or_404(Submission, id=submission_id)
    try:
        source_msg = Message.objects.select_related("conversation").get(submission=sub)
    except Message.DoesNotExist:
        raise HttpError(404, "该作品没有关联提示词链")

    messages = list(
        source_msg.conversation.messages.all().order_by("created")
    )
    rounds = []
    for i, msg in enumerate(messages):
        if msg.role != "user":
            continue
        html = css = js = None
        for reply in messages[i + 1:]:
            if reply.role == "user":
                break
            if reply.role == "assistant":
                html = reply.code_html
                css = reply.code_css
                js = reply.code_js
                break
        rounds.append({
            "question": msg.content,
            "source": msg.source,
            "prompt_level": msg.prompt_level,
            "html": html,
            "css": css,
            "js": js,
        })
    return rounds
  • Step 4: Run tests to verify they pass
cd api && uv run manage.py test submission.tests_showcase.ShowcasePromptChainTest -v 2

Expected: 5 tests PASS

  • Step 5: Run all showcase tests
cd api && uv run manage.py test submission.tests_showcase -v 2

Expected: all 17 tests PASS

  • Step 6: Commit
git add api/submission/api.py api/submission/tests_showcase.py
git commit -m "feat: add showcase prompt-chain API endpoint"

Task 7: Frontend types and API client

Files:

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

  • Modify: client/src/api.ts

  • Step 1: Add types to client/src/utils/type.ts

Append to the end of client/src/utils/type.ts:

export interface ShowcaseItem {
  submission_id: string
  username: string
  task_title: string
  task_display: number
  score: number
  view_count: number
  html: string | null
  css: string | null
  js: string | null
  has_prompt_chain: boolean
}

export interface AwardSection {
  id: number
  name: string
  description: string
  item_ordering: string
  items: ShowcaseItem[]
}

export interface ShowcaseDetail {
  submission_id: string
  username: string
  task_title: string
  task_display: number
  score: number
  view_count: number
  html: string | null
  css: string | null
  js: string | null
  awards: string[]
  has_prompt_chain: boolean
}

export interface PromptRound {
  question: string
  source: string
  prompt_level: number | null
  html: string | null
  css: string | null
  js: string | null
}
  • Step 2: Add Showcase API client to client/src/api.ts

Import the new types at the top of api.ts (add to existing import from ./utils/type):

import type {
  TutorialIn,
  ChallengeIn,
  FlagType,
  SubmissionOut,
  PromptMessage,
  TaskStatsOut,
  TaskAsset,
  AwardSection,
  ShowcaseDetail,
  PromptRound,
} from "./utils/type"

Then append before the last line of api.ts:

export const Showcase = {
  async list(): Promise<AwardSection[]> {
    const res = await http.get("/submission/showcase/")
    return res.data
  },

  async getDetail(submissionId: string): Promise<ShowcaseDetail> {
    const res = await http.get(`/submission/showcase/${submissionId}/`)
    return res.data
  },

  async getPromptChain(submissionId: string): Promise<PromptRound[]> {
    const res = await http.get(`/submission/showcase/${submissionId}/prompt-chain/`)
    return res.data
  },
}
  • Step 3: Verify TypeScript compiles
cd client && npm run build 2>&1 | head -30

Expected: build succeeds with no TypeScript errors

  • Step 4: Commit
git add client/src/utils/type.ts client/src/api.ts
git commit -m "feat: add showcase frontend types and API client"

Task 8: Add showcase routes to Vue Router

Files:

  • Modify: client/src/router.ts

  • Step 1: Add routes

In client/src/router.ts, add two routes to the routes array, after the submission route:

  {
    path: "/showcase",
    name: "showcase",
    component: () => import("./pages/Showcase.vue"),
    meta: { auth: true },
  },
  {
    path: "/showcase/:id",
    name: "showcase-detail",
    component: () => import("./pages/ShowcaseDetail.vue"),
    props: true,
    meta: { auth: true },
  },
  • Step 2: Verify dev server starts cleanly
cd client && npm run start 2>&1 | head -20

Expected: starts without errors (missing component files will cause lazy import errors only when navigating, not at startup)

  • Step 3: Commit
git add client/src/router.ts
git commit -m "feat: add /showcase routes"

Task 9: Create Showcase.vue page

Files:

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

  • Step 1: Create Showcase.vue

Create client/src/pages/Showcase.vue:

<template>
  <div class="showcase">
    <div class="header">
      <n-h2>作品广场</n-h2>
      <n-text depth="3">优秀作品展示</n-text>
    </div>

    <n-spin :show="loading">
      <n-empty v-if="!loading && awards.length === 0" description="暂无展示作品" style="margin-top: 60px" />

      <div v-for="section in awards" :key="section.id" class="award-section">
        <div class="section-header">
          <n-h3 style="margin: 0">{{ section.name }}</n-h3>
          <n-text v-if="section.description" depth="3" style="font-size: 13px">
            {{ section.description }}
          </n-text>
        </div>

        <div class="card-grid">
          <div
            v-for="item in section.items"
            :key="item.submission_id"
            class="card"
            @click="$router.push({ name: 'showcase-detail', params: { id: item.submission_id } })"
          >
            <div class="card-preview">
              <iframe
                :srcdoc="buildSrcdoc(item)"
                sandbox="allow-scripts"
                scrolling="no"
                class="preview-iframe"
              />
              <div class="preview-overlay" />
            </div>
            <div class="card-info">
              <n-flex justify="space-between" align="center">
                <n-text strong style="font-size: 13px">{{ item.username }}</n-text>
                <n-flex align="center" style="gap: 6px">
                  <n-text depth="3" style="font-size: 12px">
                    ★ {{ item.score.toFixed(1) }}
                  </n-text>
                  <n-text depth="3" style="font-size: 12px">
                    👁 {{ item.view_count }}
                  </n-text>
                </n-flex>
              </n-flex>
              <n-text depth="3" style="font-size: 12px; margin-top: 2px">
                {{ item.task_title }}
              </n-text>
            </div>
          </div>
        </div>
      </div>
    </n-spin>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue"
import { Showcase } from "../api"
import type { AwardSection, ShowcaseItem } from "../utils/type"

const loading = ref(true)
const awards = ref<AwardSection[]>([])

function buildSrcdoc(item: ShowcaseItem): string {
  const css = item.css ? `<style>${item.css}</style>` : ""
  const js = item.js ? `<script>${item.js}<\/script>` : ""
  return `<!DOCTYPE html><html><head><meta charset="utf-8">${css}</head><body>${item.html ?? ""}${js}</body></html>`
}

async function init() {
  try {
    awards.value = await Showcase.list()
  } finally {
    loading.value = false
  }
}

onMounted(init)
</script>

<style scoped>
.showcase {
  max-width: 1200px;
  margin: 0 auto;
  padding: 32px 20px;
}

.header {
  margin-bottom: 32px;
}

.award-section {
  margin-bottom: 48px;
}

.section-header {
  margin-bottom: 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 16px;
}

.card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  transition: box-shadow 0.2s, transform 0.2s;
  background: #fff;
}

.card:hover {
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  transform: translateY(-2px);
}

.card-preview {
  position: relative;
  height: 160px;
  overflow: hidden;
  background: #f9f9f9;
}

.preview-iframe {
  width: 200%;
  height: 200%;
  border: none;
  transform: scale(0.5);
  transform-origin: top left;
  pointer-events: none;
}

.preview-overlay {
  position: absolute;
  inset: 0;
}

.card-info {
  padding: 10px 12px;
  border-top: 1px solid #f0f0f0;
}
</style>
  • Step 2: Start dev server and verify page loads
cd client && npm run start

Navigate to http://localhost:3000/showcase — should show loading spinner then "暂无展示作品" (no data in dev DB yet). No console errors expected.

  • Step 3: Commit
git add client/src/pages/Showcase.vue
git commit -m "feat: add Showcase page"

Task 10: Create ShowcaseDetail.vue page

Files:

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

  • Step 1: Create ShowcaseDetail.vue

Create client/src/pages/ShowcaseDetail.vue:

<template>
  <div v-if="detail" class="detail-layout">
    <div class="preview-panel">
      <div class="back-bar">
        <n-button text @click="$router.push({ name: 'showcase' })">
          ← 返回作品广场
        </n-button>
      </div>
      <iframe
        ref="iframe"
        class="preview-iframe"
        sandbox="allow-scripts"
      />
    </div>

    <div class="info-panel">
      <div class="meta">
        <n-h3 style="margin: 0 0 4px">{{ detail.task_title }}</n-h3>
        <n-text depth="3">{{ detail.username }}</n-text>

        <n-flex style="margin-top: 12px; gap: 8px" wrap>
          <n-tag
            v-for="award in detail.awards"
            :key="award"
            type="warning"
            size="small"
          >
            {{ award }}
          </n-tag>
        </n-flex>

        <n-flex style="margin-top: 12px; gap: 16px">
          <n-statistic label="评分" :value="detail.score.toFixed(1)" />
          <n-statistic label="浏览量" :value="detail.view_count" />
        </n-flex>
      </div>

      <n-divider v-if="detail.has_prompt_chain" />

      <div v-if="detail.has_prompt_chain">
        <n-collapse>
          <n-collapse-item title="创作过程" name="chain">
            <template #header-extra>
              <n-text depth="3" style="font-size: 12px">点击展开</n-text>
            </template>
            <n-spin :show="chainLoading">
              <n-empty v-if="!chainLoading && rounds.length === 0" description="暂无记录" />
              <div v-else class="chain-layout">
                <div class="round-list">
                  <div
                    v-for="(round, i) in rounds"
                    :key="i"
                    class="round-item"
                    :class="{ active: selectedRound === i }"
                    @click="selectedRound = i"
                  >
                    <div class="round-index">{{ i + 1 }}</div>
                    <div class="round-content">
                      <div class="round-text">{{ round.question }}</div>
                      <n-flex style="gap: 4px; margin-top: 4px">
                        <span class="tag-source">
                          {{ round.source === "conversation" ? "对话" : "手动" }}
                        </span>
                        <span
                          v-if="round.prompt_level"
                          class="tag-level"
                          :style="{ color: levelColors[round.prompt_level] }"
                        >
                          L{{ round.prompt_level }}
                        </span>
                      </n-flex>
                    </div>
                  </div>
                </div>
                <div class="round-preview">
                  <div class="round-preview-label">
                    第 {{ selectedRound + 1 }} 轮效果
                  </div>
                  <iframe
                    v-if="selectedRoundSrcdoc"
                    :srcdoc="selectedRoundSrcdoc"
                    :key="selectedRound"
                    sandbox="allow-scripts"
                    class="round-iframe"
                  />
                  <n-empty v-else description="该轮无网页代码" />
                </div>
              </div>
            </n-spin>
          </n-collapse-item>
        </n-collapse>
      </div>
    </div>
  </div>

  <div v-else-if="notFound" style="padding: 40px; text-align: center">
    <n-empty description="作品不存在" />
  </div>

  <div v-else style="padding: 40px; text-align: center">
    <n-spin />
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef, watch } from "vue"
import { Showcase } from "../api"
import type { ShowcaseDetail, PromptRound } from "../utils/type"

interface Props {
  id: string
}
const props = defineProps<Props>()

const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
const detail = ref<ShowcaseDetail | null>(null)
const notFound = ref(false)
const rounds = ref<PromptRound[]>([])
const chainLoading = ref(false)
const selectedRound = ref(0)
const chainLoaded = ref(false)

const levelColors: Record<number, string> = {
  1: "#aaa",
  2: "#6aab9c",
  3: "#4a9ade",
  4: "#c47ab8",
  5: "#e0a040",
  6: "#e05c5c",
}

const selectedRoundSrcdoc = computed(() => {
  const r = rounds.value[selectedRound.value]
  if (!r?.html) return null
  const style = r.css ? `<style>${r.css}</style>` : ""
  const script = r.js ? `<script>${r.js}<\/script>` : ""
  return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${r.html}${script}</body></html>`
})

function renderPreview() {
  if (!iframe.value || !detail.value) return
  const d = detail.value
  const css = d.css ? `<style>${d.css}</style>` : ""
  const js = d.js ? `<script>${d.js}<\/script>` : ""
  const html = `<!DOCTYPE html><html><head><meta charset="utf-8">${css}</head><body>${d.html ?? ""}${js}</body></html>`
  const doc = iframe.value.contentDocument
  if (doc) {
    doc.open()
    doc.write(html)
    doc.close()
  }
}

async function loadChain() {
  if (chainLoaded.value) return
  chainLoading.value = true
  try {
    rounds.value = await Showcase.getPromptChain(props.id)
    selectedRound.value = Math.max(0, rounds.value.length - 1)
    chainLoaded.value = true
  } finally {
    chainLoading.value = false
  }
}

async function init() {
  try {
    detail.value = await Showcase.getDetail(props.id)
    await new Promise((r) => setTimeout(r, 0))
    renderPreview()
  } catch {
    notFound.value = true
  }
}

watch(
  () => detail.value,
  (v) => {
    if (v) renderPreview()
  },
)

onMounted(init)
</script>

<style scoped>
.detail-layout {
  display: flex;
  height: 100vh;
  overflow: hidden;
}

.back-bar {
  padding: 8px 12px;
  border-bottom: 1px solid #f0f0f0;
  flex-shrink: 0;
}

.preview-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  border-right: 1px solid #e0e0e0;
}

.preview-iframe {
  flex: 1;
  border: none;
  width: 100%;
}

.info-panel {
  width: 320px;
  flex-shrink: 0;
  padding: 20px 16px;
  overflow-y: auto;
}

.meta {
  display: flex;
  flex-direction: column;
}

.chain-layout {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  min-height: 300px;
}

.round-list {
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 400px;
}

.round-item {
  display: flex;
  gap: 8px;
  cursor: pointer;
  padding: 8px;
  border-radius: 6px;
  border: 1px solid #e0e0e0;
  background: #f9f9f9;
  transition: background 0.15s;
}

.round-item.active {
  background: #e8f0fe;
  border-color: #2080f0;
}

.round-index {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #c2d5fb;
  color: #fff;
  font-size: 11px;
  font-weight: bold;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.round-item.active .round-index {
  background: #2080f0;
}

.round-text {
  font-size: 12px;
  line-height: 1.5;
  color: #333;
}

.tag-source {
  font-size: 10px;
  padding: 1px 5px;
  border-radius: 4px;
  background: #f0f0f0;
  color: #888;
}

.tag-level {
  font-size: 11px;
  font-weight: bold;
}

.round-preview {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.round-preview-label {
  font-size: 12px;
  font-weight: bold;
  color: #555;
}

.round-iframe {
  flex: 1;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  min-height: 260px;
}
</style>
  • Step 2: Wire up chain loading on collapse open

The n-collapse-item in Naive UI emits via the parent n-collapse's @update:expanded-names. Update the collapse in ShowcaseDetail.vue to load chain on first expand:

<n-collapse @update:expanded-names="onCollapseChange">

Add the handler in <script setup>:

function onCollapseChange(names: string[]) {
  if (names.includes("chain")) loadChain()
}
  • Step 3: Verify page loads in browser

With dev server running, navigate to /showcase then click a card (if test data exists) or directly to /showcase/some-uuid. Verify:

  • Back button works

  • Main preview renders

  • Awards and stats show

  • Chain section shows only when has_prompt_chain is true

  • Step 4: Commit

git add client/src/pages/ShowcaseDetail.vue
git commit -m "feat: add ShowcaseDetail page with prompt-chain viewer"

Task 11: Add showcase nav entry in TaskPanel

Files:

  • Modify: client/src/components/task/TaskPanel.vue

  • Step 1: Add nav button

In client/src/components/task/TaskPanel.vue, find the block with the "提交记录" button:

<n-tooltip v-if="authed" trigger="hover">
  <template #trigger>
    <n-button
      text
      @click="
        $router.push({ name: 'submissions', params: { page: 1 } })
      "
    >
      <Icon :width="16" icon="lucide:list"></Icon>
    </n-button>
  </template>
  提交记录
</n-tooltip>

Add the showcase nav button immediately after it:

<n-tooltip v-if="authed" trigger="hover">
  <template #trigger>
    <n-button text @click="$router.push({ name: 'showcase' })">
      <Icon :width="16" icon="lucide:award"></Icon>
    </n-button>
  </template>
  作品广场
</n-tooltip>
  • Step 2: Verify button appears when logged in

With dev server running, log in and verify the lucide:award icon appears in the TaskPanel toolbar next to the submissions list icon.

  • Step 3: Commit
git add client/src/components/task/TaskPanel.vue
git commit -m "feat: add showcase nav entry in TaskPanel"

Task 12: Run all tests and final check

  • Step 1: Run all showcase backend tests
cd api && uv run manage.py test submission.tests_showcase -v 2

Expected: 17 tests PASS, 0 failures

  • Step 2: Run all backend tests
cd api && uv run manage.py test -v 1

Expected: all tests PASS

  • Step 3: Run frontend build
cd client && npm run build 2>&1 | tail -10

Expected: successful build, no TypeScript errors

  • Step 4: Final commit if any cleanup needed
git add -p
git commit -m "chore: showcase feature cleanup"