- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1582 lines
44 KiB
Markdown
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"
|
|
```
|