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

1582 lines
44 KiB
Markdown

# 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:
```python
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**
```bash
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**
```bash
cd api && uv run manage.py migrate
```
Expected: `OK` with no errors
- [ ] **Step 4: Commit**
```bash
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`:
```python
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)**
```bash
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`:
```python
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**
```bash
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`:
```python
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**
```bash
cd api && uv run manage.py check
```
Expected: `System check identified no issues.`
- [ ] **Step 3: Commit**
```bash
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`:
```python
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**
```bash
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):
```python
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}", ...)`):
```python
@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**
```bash
cd api && uv run manage.py test submission.tests_showcase.ShowcaseListTest -v 2
```
Expected: 8 tests PASS
- [ ] **Step 5: Commit**
```bash
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`:
```python
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**
```bash
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`:
```python
@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**
```bash
cd api && uv run manage.py test submission.tests_showcase.ShowcaseDetailTest -v 2
```
Expected: 4 tests PASS
- [ ] **Step 5: Commit**
```bash
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`:
```python
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**
```bash
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`:
```python
@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**
```bash
cd api && uv run manage.py test submission.tests_showcase.ShowcasePromptChainTest -v 2
```
Expected: 5 tests PASS
- [ ] **Step 5: Run all showcase tests**
```bash
cd api && uv run manage.py test submission.tests_showcase -v 2
```
Expected: all 17 tests PASS
- [ ] **Step 6: Commit**
```bash
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`:
```typescript
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`):
```typescript
import type {
TutorialIn,
ChallengeIn,
FlagType,
SubmissionOut,
PromptMessage,
TaskStatsOut,
TaskAsset,
AwardSection,
ShowcaseDetail,
PromptRound,
} from "./utils/type"
```
Then append before the last line of `api.ts`:
```typescript
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**
```bash
cd client && npm run build 2>&1 | head -30
```
Expected: build succeeds with no TypeScript errors
- [ ] **Step 4: Commit**
```bash
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:
```typescript
{
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**
```bash
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**
```bash
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`:
```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**
```bash
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**
```bash
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`:
```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:
```vue
<n-collapse @update:expanded-names="onCollapseChange">
```
Add the handler in `<script setup>`:
```typescript
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**
```bash
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:
```vue
<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:
```vue
<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**
```bash
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**
```bash
cd api && uv run manage.py test submission.tests_showcase -v 2
```
Expected: 17 tests PASS, 0 failures
- [ ] **Step 2: Run all backend tests**
```bash
cd api && uv run manage.py test -v 1
```
Expected: all tests PASS
- [ ] **Step 3: Run frontend build**
```bash
cd client && npm run build 2>&1 | tail -10
```
Expected: successful build, no TypeScript errors
- [ ] **Step 4: Final commit if any cleanup needed**
```bash
git add -p
git commit -m "chore: showcase feature cleanup"
```