- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 testsclient/src/pages/Showcase.vue— award grid pageclient/src/pages/ShowcaseDetail.vue— submission detail + prompt chain
Modify:
api/submission/models.py— addAward,SubmissionAward,ItemOrderingapi/submission/admin.py— registerAward,SubmissionAwardapi/submission/schemas.py— addShowcaseItemOut,AwardOut,ShowcaseDetailOut,PromptRoundOutapi/submission/api.py— add showcase endpointsclient/src/utils/type.ts— add showcase typesclient/src/api.ts— addShowcaseobjectclient/src/router.ts— add/showcaseand/showcase/:idroutesclient/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_chainis 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"