Phase 2: T-201 至 T-206 前台 MVP 全部实现(11 templates + CSS + 31 tests)

This commit is contained in:
ila
2026-07-06 22:57:08 +08:00
parent 5604e2cde5
commit cff3c64c19
20 changed files with 996 additions and 98 deletions
+2 -2
View File
@@ -10,9 +10,9 @@ Skelet 是一个介绍、分类、评测开源项目骨架的网站。目标用
## 当前阶段
Phase 1(内容模型)完成。T-101 / T-102 / T-103 / T-104 已实现并验收。
Phase 2(前台 MVP)完成。T-201 至 T-206 已实现(基础页面框架、首页、场景页、项目列表、筛选搜索、详情页、文章列表与详情)。
下一步:进入 Phase 2,从 [`docs/06-tasks.md`](docs/06-tasks.md) 领取 `T-201`:实现基础页面框架。
下一步:进入 Phase 3,从 [`docs/06-tasks.md`](docs/06-tasks.md) 领取 `T-301`:补 SEO 基础。
## 开发环境
+70
View File
@@ -1,4 +1,6 @@
from django.core.paginator import Paginator, EmptyPage
from django.db import models
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from modelcluster.fields import ParentalManyToManyField
@@ -25,6 +27,18 @@ class ScenarioPage(Page):
content_panels = Page.content_panels
template = "core/scenario_page.html"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["projects"] = (
SkeletonProjectPage.objects.live()
.filter(scenarios=self)
.prefetch_related("languages", "scenarios")
.order_by("-first_published_at")
)
return context
class Meta:
verbose_name = "Scenario"
@@ -112,11 +126,65 @@ class ProjectIndexPage(Page):
content_panels = Page.content_panels
template = "core/project_index_page.html"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
projects = (
SkeletonProjectPage.objects.live()
.prefetch_related("languages", "scenarios")
.order_by("-first_published_at")
)
language = request.GET.get("language", "")
framework = request.GET.get("framework", "")
database = request.GET.get("database", "")
min_score = request.GET.get("min_score", "")
q = request.GET.get("q", "")
if language:
projects = projects.filter(languages__slug=language)
if framework:
projects = projects.filter(frameworks__slug=framework)
if database:
projects = projects.filter(databases__slug=database)
try:
score_val = int(min_score)
if 0 <= score_val <= 30:
projects = [
p for p in projects if p.total_score >= score_val
]
except (ValueError, TypeError):
pass
if q:
projects = projects.filter(
Q(title__icontains=q) | Q(summary__icontains=q)
)
paginator = Paginator(projects, 20)
page = request.GET.get("page", "1")
try:
context["projects"] = paginator.page(int(page))
except (ValueError, EmptyPage):
context["projects"] = paginator.page(1)
context["current_language"] = language
context["current_framework"] = framework
context["current_database"] = database
context["current_min_score"] = min_score
context["current_q"] = q
context["languages"] = Language.objects.all()
context["frameworks"] = Framework.objects.all()
context["databases"] = DatabaseOption.objects.all()
return context
class Meta:
verbose_name = "Project Index"
class SkeletonProjectPage(Page):
template = "core/skeleton_project_page.html"
MATURITY_CHOICES = [
("experimental", "Experimental"),
("stable", "Stable"),
@@ -255,6 +323,7 @@ class SkeletonProjectPage(Page):
class ArticleIndexPage(Page):
template = "core/article_index_page.html"
parent_page_types = ["home.HomePage"]
subpage_types = ["core.ArticlePage"]
max_count = 1
@@ -266,6 +335,7 @@ class ArticleIndexPage(Page):
class ArticlePage(Page):
template = "core/article_page.html"
body = RichTextField()
related_projects = ParentalManyToManyField(
"core.SkeletonProjectPage", blank=True
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% if page.get_children.live %}
<div class="card-grid">
{% for article in page.get_children.live %}
<a class="card" href="{% url 'wagtail_serve' '' %}articles/{{ article.slug }}/">
<h3>{{ article.title }}</h3>
</a>
{% endfor %}
</div>
{% else %}
{% include "includes/empty_state.html" with message="No articles yet." %}
{% endif %}
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
<div class="rich-text">{{ page.body|safe }}</div>
{% if page.related_projects.exists %}
<h2>Related Projects</h2>
<div class="card-grid">
{% for project in page.related_projects.all %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>{{ project.title }}</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
<form class="filters" method="get">
<label>
Language:
<select name="language">
<option value="">All</option>
{% for lang in languages %}
<option value="{{ lang.slug }}" {% if current_language == lang.slug %}selected{% endif %}>{{ lang.name }}</option>
{% endfor %}
</select>
</label>
<label>
Framework:
<select name="framework">
<option value="">All</option>
{% for fw in frameworks %}
<option value="{{ fw.slug }}" {% if current_framework == fw.slug %}selected{% endif %}>{{ fw.name }}</option>
{% endfor %}
</select>
</label>
<label>
Database:
<select name="database">
<option value="">All</option>
{% for db in databases %}
<option value="{{ db.slug }}" {% if current_database == db.slug %}selected{% endif %}>{{ db.name }}</option>
{% endfor %}
</select>
</label>
<label>
Min Score:
<input type="number" name="min_score" min="0" max="30" value="{{ current_min_score }}" style="width:5ch;">
</label>
<label>
Search:
<input type="text" name="q" value="{{ current_q }}" placeholder="Keyword...">
</label>
<button type="submit">Filter</button>
</form>
{% if projects %}
<div class="card-grid">
{% for project in projects %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>{{ project.title }}</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
<div class="score-bar">
<span class="score-total">{{ project.total_score }}/30</span>
<span class="score-detail">AI-Friendly Score</span>
</div>
<div class="tag-list">
{% for lang in project.languages.all|slice:":3" %}
<span class="tag">{{ lang.name }}</span>
{% endfor %}
</div>
</a>
{% endfor %}
</div>
{% if projects.paginator.num_pages > 1 %}
<nav class="pagination">
{% if projects.has_previous %}
<a href="?{% for k,v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ projects.previous_page_number }}">&laquo; Prev</a>
{% endif %}
<span class="current">Page {{ projects.number }} of {{ projects.paginator.num_pages }}</span>
{% if projects.has_next %}
<a href="?{% for k,v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ projects.next_page_number }}">Next &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
{% include "includes/empty_state.html" with message="No projects match your filters." %}
{% endif %}
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% if projects %}
<div class="card-grid">
{% for project in projects %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>{{ project.title }}</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
<div class="score-bar">
<span class="score-total">{{ project.total_score }}/30</span>
<span class="score-detail">AI-Friendly Score</span>
</div>
<div class="tag-list">
{% for lang in project.languages.all|slice:":3" %}
<span class="tag">{{ lang.name }}</span>
{% endfor %}
</div>
</a>
{% endfor %}
</div>
{% else %}
{% include "includes/empty_state.html" with message="No projects found in this scenario." %}
{% endif %}
{% endblock %}
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<article>
<h1>
{{ page.title }}
{% if page.is_featured %}<span class="badge badge-featured">Featured</span>{% endif %}
{% if page.is_sponsored %}{% include "includes/sponsored_badge.html" %}{% endif %}
</h1>
<p class="rich-text">{{ page.summary }}</p>
<table class="data-table">
<tr><th>GitHub</th><td><a href="{{ page.github_url }}" target="_blank" rel="noopener">{{ page.github_url }}</a></td></tr>
{% if page.official_url %}
<tr><th>Official Site</th><td><a href="{{ page.official_url }}" target="_blank" rel="noopener">{{ page.official_url }}</a></td></tr>
{% endif %}
{% if page.license_name %}
<tr><th>License</th><td>{{ page.license_name }}</td></tr>
{% endif %}
<tr><th>Maturity</th><td>{{ page.get_maturity_display }}</td></tr>
<tr><th>Maintenance</th><td>{{ page.get_maintenance_status_display }}</td></tr>
{% if page.scenarios.exists %}
<tr><th>Scenarios</th><td>
{% for s in page.scenarios.all %}<span class="tag">{{ s.title }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.languages.exists %}
<tr><th>Languages</th><td>
{% for l in page.languages.all %}<span class="tag">{{ l.name }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.frameworks.exists %}
<tr><th>Frameworks</th><td>
{% for f in page.frameworks.all %}<span class="tag">{{ f.name }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.databases.exists %}
<tr><th>Databases</th><td>
{% for d in page.databases.all %}<span class="tag">{{ d.name }}</span>{% endfor %}
</td></tr>
{% endif %}
{% if page.features.exists %}
<tr><th>Features</th><td>
{% for feat in page.features.all %}<span class="tag">{{ feat.name }}</span>{% endfor %}
</td></tr>
{% endif %}
</table>
<h2>Scores</h2>
<table class="data-table">
<tr><th>Structure</th><td>{{ page.structure_score }}/5</td></tr>
<tr><th>Documentation</th><td>{{ page.docs_score }}/5</td></tr>
<tr><th>Testing</th><td>{{ page.tests_score }}/5</td></tr>
<tr><th>Examples</th><td>{{ page.example_score }}/5</td></tr>
<tr><th>Dependency Control</th><td>{{ page.dependency_score }}/5</td></tr>
<tr><th>Incremental Development</th><td>{{ page.incremental_score }}/5</td></tr>
<tr><th>Total</th><td><strong>{{ page.total_score }}/30</strong></td></tr>
</table>
<h2>Recommended For</h2>
<div class="rich-text">{{ page.recommended_for|safe }}</div>
{% if page.not_recommended_for %}
<h2>Not Recommended For</h2>
<div class="rich-text">{{ page.not_recommended_for|safe }}</div>
{% endif %}
{% if page.review_notes %}
<h2>Review Notes</h2>
<div class="rich-text">{{ page.review_notes|safe }}</div>
{% endif %}
</article>
{% endblock %}
+200 -17
View File
@@ -20,6 +20,206 @@ from core.models import (
)
class PageTreeMixin:
@classmethod
def setUpPageTree(cls):
cls.home = HomePage.objects.get(slug="home")
cls.scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
cls.home.add_child(instance=cls.scenario_index)
cls.scenario = ScenarioPage(title="SaaS", slug="saas")
cls.scenario_index.add_child(instance=cls.scenario)
cls.project_index = ProjectIndexPage(title="Projects", slug="projects")
cls.home.add_child(instance=cls.project_index)
cls.article_index = ArticleIndexPage(title="Articles", slug="articles")
cls.home.add_child(instance=cls.article_index)
class ScenarioPageTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Python", slug="python")
def test_scenario_page_with_projects(self):
project = SkeletonProjectPage(
title="Scenario Project",
slug="scenario-project",
summary="A project in this scenario",
github_url="https://github.com/test/sproject",
maturity="stable",
recommended_for="Testing",
structure_score=3, docs_score=3, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
response = self.client.get(self.scenario.url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scenario Project")
def test_empty_scenario_page(self):
ScenarioPage.objects.create(
title="Empty Scenario",
slug="empty-scenario",
path=self.scenario_index.path + "9999",
depth=self.scenario_index.depth + 1,
)
response = self.client.get("/scenarios/empty-scenario/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Nothing here yet")
class ProjectFilterTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang_py = Language.objects.create(name="Python", slug="python")
cls.lang_go = Language.objects.create(name="Go", slug="go")
cls.fw_dj = Framework.objects.create(name="Django", slug="django")
cls.db_pg = DatabaseOption.objects.create(name="PostgreSQL", slug="postgresql")
cls.proj_a = SkeletonProjectPage(
title="Django Project",
slug="django-project",
summary="A Django project for testing",
github_url="https://github.com/test/django",
maturity="stable",
recommended_for="Testing",
structure_score=5, docs_score=5, tests_score=5,
example_score=5, dependency_score=5, incremental_score=5,
)
cls.project_index.add_child(instance=cls.proj_a)
cls.proj_a.languages.add(cls.lang_py)
cls.proj_a.frameworks.add(cls.fw_dj)
cls.proj_a.databases.add(cls.db_pg)
cls.proj_a.scenarios.add(cls.scenario)
cls.proj_a.save()
cls.proj_b = SkeletonProjectPage(
title="Go Project",
slug="go-project",
summary="A Go project for testing",
github_url="https://github.com/test/go",
maturity="experimental",
recommended_for="Testing",
structure_score=1, docs_score=1, tests_score=1,
example_score=1, dependency_score=1, incremental_score=1,
)
cls.project_index.add_child(instance=cls.proj_b)
cls.proj_b.languages.add(cls.lang_go)
cls.proj_b.scenarios.add(cls.scenario)
cls.proj_b.save()
def test_language_filter(self):
resp = self.client.get("/projects/?language=python")
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_min_score_filter(self):
resp = self.client.get("/projects/?min_score=18")
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_keyword_search(self):
resp = self.client.get("/projects/?q=Django")
self.assertContains(resp, "Django Project")
self.assertNotContains(resp, "Go Project")
def test_invalid_params_ignored(self):
resp = self.client.get("/projects/?min_score=abc&page=xyz")
self.assertEqual(resp.status_code, 200)
class SkeletonProjectDetailTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Ruby", slug="ruby")
def test_detail_shows_scores_and_recommendation(self):
project = SkeletonProjectPage(
title="Detail Test",
slug="detail-test",
summary="Testing detail page",
github_url="https://github.com/test/detail",
maturity="stable",
recommended_for="Developers who need structure",
structure_score=4, docs_score=3, tests_score=3,
example_score=4, dependency_score=3, incremental_score=4,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
resp = self.client.get(project.url)
self.assertContains(resp, "Detail Test")
self.assertContains(resp, "21/30")
self.assertContains(resp, "Developers who need structure")
def test_sponsored_badge(self):
project = SkeletonProjectPage(
title="Sponsored Project",
slug="sponsored-project",
summary="A sponsored test",
github_url="https://github.com/test/sponsored",
maturity="experimental",
recommended_for="Sponsored users",
structure_score=2, docs_score=2, tests_score=2,
example_score=2, dependency_score=2, incremental_score=2,
is_sponsored=True,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
resp = self.client.get(project.url)
self.assertContains(resp, "Sponsored")
class ArticleDetailTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Rust", slug="rust")
def test_article_links_to_project(self):
project = SkeletonProjectPage(
title="Linked Project",
slug="linked-project",
summary="Project linked from article",
github_url="https://github.com/test/linked",
maturity="stable",
recommended_for="Testing",
structure_score=3, docs_score=3, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
article = ArticlePage(
title="Review Article",
slug="review-article",
body="<p>About the linked project.</p>",
)
self.article_index.add_child(instance=article)
article.related_projects.add(project)
article.save()
resp = self.client.get(article.url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "/projects/linked-project/")
class LanguageTests(TestCase):
def test_create_language(self):
lang = Language.objects.create(name="Python", slug="python")
@@ -64,23 +264,6 @@ class SkeletonFeatureTests(TestCase):
SkeletonFeature.objects.create(name="Docker2", slug="docker")
class PageTreeMixin:
@classmethod
def setUpPageTree(cls):
cls.home = HomePage.objects.get(slug="home")
cls.scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
cls.home.add_child(instance=cls.scenario_index)
cls.scenario = ScenarioPage(title="SaaS", slug="saas")
cls.scenario_index.add_child(instance=cls.scenario)
cls.project_index = ProjectIndexPage(title="Projects", slug="projects")
cls.home.add_child(instance=cls.project_index)
cls.article_index = ArticleIndexPage(title="Articles", slug="articles")
cls.home.add_child(instance=cls.article_index)
class SkeletonProjectPageTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
+6 -6
View File
@@ -39,12 +39,12 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- |
| T-201 | 实现基础页面框架 | T-104 | `base.html`、导航、页脚、纯 CSS 基础样式;含 viewport meta;无超过视口的固定宽度容器 | TODO |
| T-202 | 实现首页 | T-201 | 首页展示场景入口、推荐骨架和最新文章 | TODO |
| T-203 | 实现场景页和项目列表页 | T-202 | 场景页展示对应项目;项目列表支持分页和空状态 | TODO |
| T-204 | 实现筛选与基础搜索 | T-203 | 严格按 `04-architecture.md` §3.4 参数契约实现筛选和关键词搜索 | TODO |
| T-205 | 实现骨架详情页 | T-204 | 展示需求文档要求的详情字段、评分、推荐理由和 Sponsored 标识 | TODO |
| T-206 | 实现文章列表和文章详情 | T-203 | 文章可访问,可链接项目详情 | TODO |
| T-201 | 实现基础页面框架 | T-104 | `base.html`、导航、页脚、纯 CSS 基础样式;含 viewport meta;无超过视口的固定宽度容器 | DONE |
| T-202 | 实现首页 | T-201 | 首页展示场景入口、推荐骨架和最新文章 | DONE |
| T-203 | 实现场景页和项目列表页 | T-202 | 场景页展示对应项目;项目列表支持分页和空状态 | DONE |
| T-204 | 实现筛选与基础搜索 | T-203 | 严格按 `04-architecture.md` §3.4 参数契约实现筛选和关键词搜索 | DONE |
| T-205 | 实现骨架详情页 | T-204 | 展示需求文档要求的详情字段、评分、推荐理由和 Sponsored 标识 | DONE |
| T-206 | 实现文章列表和文章详情 | T-203 | 文章可访问,可链接项目详情 | DONE |
## Phase 3 · SEO 与上线准备
+4 -4
View File
@@ -11,14 +11,14 @@
## 当前快照
- 日期:2026-07-06
- 阶段:T-001 已完成,Wagtail 7.4.2 + Django 6.0.6 项目已初始化;生产代码可运行
- 阶段:Phase 2(前台 MVP)已完成,T-201 至 T-206 全部实现
- 开发环境:MSYS2/MinGW Python 3.12.12,venv 使用 `--system-site-packages`(Pillow 由 MSYS2 预编译包提供)
- git:分支 `ds`;`.gitignore` 已生效(`.venv/`、`db.sqlite3`、`media/` 等被忽略)
- 技术栈:Wagtail 7.4.2 + Django 6.0.6 + Python 3.12 + SQLite;第一版英文单语言站点
- 生产代码:已初始化,`manage.py`、`skelet/`、`home/`、`search/`、`core/` 已建
- 测试:`home/tests.py` 含 5 个测试,`core/tests.py` 含 17 个测试,全部通过
- 数据:3 个场景、5 个骨架项目、2 篇文章已录入(`core/management/commands/seed_data.py`)
- 当前 blocker:无;下一步执行 `T-201`(Phase 2 前台 MVP)
- 当前 blocker:无;下一步执行 `T-301`(Phase 3 SEO 与上线准备)
## 当前目录要点
@@ -42,9 +42,9 @@
## 任务看板状态
- 已完成:`T-000`、`T-001`、`T-002`、`T-003`、`T-101`、`T-102`、`T-103`、`T-104`。
- 已完成:T-000 至 T-104(Phase 0+1)、T-201 至 T-206(Phase 2)。
- 正在进行:无。
- 下一个可领取任务:`T-201 实现基础页面框架`。
- 下一个可领取任务:`T-301 补 SEO 基础`(Phase 3)。
## 当前可运行内容
+18 -1
View File
@@ -2,6 +2,23 @@ from django.db import models
from wagtail.models import Page
from core.models import ScenarioPage, SkeletonProjectPage, ArticlePage
class HomePage(Page):
pass
parent_page_types = ["wagtailcore.Page"]
max_count = 1
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["scenarios"] = ScenarioPage.objects.live()
context["featured_projects"] = (
SkeletonProjectPage.objects.live().filter(is_featured=True)
.prefetch_related("scenarios", "languages")
.order_by("-first_published_at")[:6]
)
context["latest_articles"] = (
ArticlePage.objects.live()
.order_by("-first_published_at")[:4]
)
return context
+56 -13
View File
@@ -1,21 +1,64 @@
{% extends "base.html" %}
{% load static %}
{% block body_class %}template-homepage{% endblock %}
{% block extra_css %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
<link rel="stylesheet" href="{% static 'css/welcome_page.css' %}">
{% endblock extra_css %}
{% block content %}
{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
{% include 'home/welcome_page.html' %}
<h1>Find the Right Project Skeleton</h1>
<p>Browse open-source project skeletons by scenario, language, framework, and AI-coding friendliness score.</p>
{% if scenarios %}
<section>
<h2>Scenarios</h2>
<div class="card-grid">
{% for scenario in scenarios %}
<a class="card" href="{% url 'wagtail_serve' '' %}scenarios/{{ scenario.slug }}/">
<h3>{{ scenario.title }}</h3>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% if featured_projects %}
<section>
<h2>Featured Projects</h2>
<div class="card-grid">
{% for project in featured_projects %}
<a class="card" href="{% url 'wagtail_serve' '' %}projects/{{ project.slug }}/">
<h3>
{{ project.title }}
{% if project.is_sponsored %}
<span class="badge badge-sponsored">Sponsored</span>
{% endif %}
</h3>
<p>{{ project.summary|truncatewords:20 }}</p>
<div class="score-bar">
<span class="score-total">{{ project.total_score }}/30</span>
<span class="score-detail">AI-Friendly Score</span>
</div>
<div class="tag-list">
{% for lang in project.languages.all|slice:":3" %}
<span class="tag">{{ lang.name }}</span>
{% endfor %}
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% if latest_articles %}
<section>
<h2>Latest Articles</h2>
<div class="card-grid">
{% for article in latest_articles %}
<a class="card" href="{% url 'wagtail_serve' '' %}articles/{{ article.slug }}/">
<h3>{{ article.title }}</h3>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock content %}
+53 -15
View File
@@ -5,6 +5,14 @@ from home.models import HomePage
from wagtail.models import Page, Site
from wagtail.test.utils import WagtailPageTestCase
from core.models import (
Language,
ScenarioPage,
SkeletonProjectPage,
ScenarioIndexPage,
ProjectIndexPage,
)
class Smoketest(TestCase):
def test_homepage_returns_200(self):
@@ -12,33 +20,63 @@ class Smoketest(TestCase):
self.assertEqual(response.status_code, 200)
class HomeSetUpTests(WagtailPageTestCase):
"""
Tests for basic page structure setup and HomePage creation.
"""
class HomepageContextTests(TestCase):
@classmethod
def setUpTestData(cls):
root = Page.get_first_root_node()
cls.home = HomePage.objects.get(slug="home")
scenario_index = ScenarioIndexPage(title="Scenarios", slug="scenarios")
cls.home.add_child(instance=scenario_index)
cls.scenario = ScenarioPage(title="Test Scenario", slug="test-scenario")
scenario_index.add_child(instance=cls.scenario)
project_index = ProjectIndexPage(title="Projects", slug="projects")
cls.home.add_child(instance=project_index)
lang = Language.objects.create(name="Python", slug="python")
project = SkeletonProjectPage(
title="Featured Project",
slug="featured-project",
summary="A featured test project",
github_url="https://github.com/test/featured",
maturity="stable",
recommended_for="Testing",
structure_score=4, docs_score=4, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
is_featured=True,
)
project_index.add_child(instance=project)
project.languages.add(lang)
project.scenarios.add(cls.scenario)
project.save()
def test_homepage_contains_featured_project(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Featured Project")
class HomeSetUpTests(WagtailPageTestCase):
def test_root_create(self):
root_page = Page.objects.get(pk=1)
self.assertIsNotNone(root_page)
def test_homepage_create(self):
root_page = Page.objects.get(pk=1)
homepage = HomePage(title="Home")
homepage = HomePage(title="Home Test")
root_page.add_child(instance=homepage)
self.assertTrue(HomePage.objects.filter(title="Home").exists())
self.assertTrue(HomePage.objects.filter(title="Home Test").exists())
class HomeTests(WagtailPageTestCase):
"""
Tests for homepage functionality and rendering.
"""
def setUp(self):
"""
Create a homepage instance for testing.
"""
root_page = Page.get_first_root_node()
Site.objects.create(hostname="testsite", root_page=root_page, is_default_site=True)
Site.objects.create(
hostname="testsite", root_page=root_page, is_default_site=True
)
self.homepage = HomePage(title="Home")
root_page.add_child(instance=self.homepage)
@@ -47,4 +85,4 @@ class HomeTests(WagtailPageTestCase):
def test_homepage_template_used(self):
response = self.client.get(self.homepage.url)
self.assertTemplateUsed(response, "home/home_page.html")
self.assertTemplateUsed(response, "home/home_page.html")
+9 -1
View File
@@ -255,7 +255,15 @@
- `core/tests.py`:新增 `test_m2m_persists_after_save_and_reload` 测试——save() 后从 DB 重新加载,断言 `scenarios.count() >= 1` 和 `languages.count() >= 1`
- 验证:
- `manage.py seed_data --clear`:5 个项目全部 `scenarios≥1, languages≥1 [OK]`,2 篇文章全部 `related_projects=1 [OK]`
- `manage.py test core home`:21 tests passed(新增 1 个持久化测试)
- `manage.py test core home`:21 tests passed(新增 1 个持久化测试)
- 阻塞:无。
- 下一步:T-201 实现基础页面框架(Phase 2 前台 MVP)。
## 2026-07-06 T-201 至 T-206 Phase 2 前台 MVP
- 状态:全部 DONE
- 变更:T-201 基础框架(base.html + nav/footer + CSS),T-202 首页(scenarios/featured/articles),T-203 场景页 + 项目列表(分页+空状态),T-204 筛选搜索(§3.4 契约),T-205 详情页(scores/badge),T-206 文章列表+详情(含 project links)
- 验证:`manage.py test core home` 31 tests passed
- 阻塞:无。
- 下一步:T-301 补 SEO 基础(Phase 3)。
+288
View File
@@ -0,0 +1,288 @@
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
line-height: 1.6;
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color: #1a1a2e;
background: #fafafa;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 1rem;
}
/* Nav */
.site-nav {
background: #1a1a2e;
color: #fff;
padding: 0.75rem 0;
}
.site-nav .container {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.nav-logo {
font-size: 1.25rem;
font-weight: 700;
color: #fff;
text-decoration: none;
}
.nav-links {
list-style: none;
display: flex;
gap: 1.5rem;
}
.nav-links a {
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
font-size: 0.9375rem;
}
.nav-links a:hover {
color: #fff;
}
/* Main */
.site-main {
flex: 1;
padding: 2rem 0;
}
/* Footer */
.site-footer {
background: #1a1a2e;
color: rgba(255, 255, 255, 0.6);
text-align: center;
padding: 1rem 0;
font-size: 0.875rem;
}
/* Typography */
h1, h2, h3 {
line-height: 1.3;
}
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h3 { font-size: 1.25rem; margin-bottom: 0.375rem; }
p { margin-bottom: 1rem; }
a { color: #2563eb; }
a:hover { text-decoration: underline; }
/* Card grid */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
margin-top: 1rem;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.25rem;
text-decoration: none;
color: inherit;
display: block;
}
.card:hover {
border-color: #2563eb;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
}
.card h3 {
font-size: 1.125rem;
margin-bottom: 0.375rem;
}
.card p {
font-size: 0.9375rem;
color: #6b7280;
margin-bottom: 0;
}
/* Badge */
.badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 4px;
margin-right: 0.375rem;
}
.badge-sponsored {
background: #fef3c7;
color: #92400e;
}
.badge-featured {
background: #dbeafe;
color: #1e40af;
}
/* Score bar */
.score-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.score-total {
font-weight: 700;
font-size: 1.125rem;
color: #2563eb;
}
.score-detail {
font-size: 0.8125rem;
color: #6b7280;
}
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.75rem;
}
.tag {
background: #f3f4f6;
color: #374151;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
/* Table */
.data-table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
.data-table th,
.data-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.data-table th {
font-weight: 600;
color: #374151;
font-size: 0.875rem;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.empty-state h2 {
color: #374151;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 2rem;
}
.pagination a,
.pagination span {
padding: 0.375rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 0.875rem;
text-decoration: none;
color: #374151;
}
.pagination .current {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
/* Filters */
.filters {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.filters label {
font-size: 0.875rem;
color: #374151;
}
.filters select,
.filters input {
padding: 0.375rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.875rem;
}
.filters button {
padding: 0.375rem 1rem;
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
}
.filters button:hover {
background: #1d4ed8;
}
/* Rich text */
.rich-text h1 { font-size: 2rem; margin: 1.5rem 0 0.5rem; }
.rich-text h2 { font-size: 1.5rem; margin: 1.25rem 0 0.5rem; }
.rich-text h3 { font-size: 1.25rem; margin: 1rem 0 0.375rem; }
.rich-text ul, .rich-text ol { padding-left: 1.5rem; margin-bottom: 1rem; }
.rich-text li { margin-bottom: 0.25rem; }
+40 -39
View File
@@ -2,45 +2,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>
{% block title %}
{% if page.seo_title %}{{ page.seo_title }}{% else %}{{ page.title }}{% endif %}
{% endblock %}
{% block title_suffix %}
{% wagtail_site as current_site %}
{% if current_site and current_site.site_name %}- {{ current_site.site_name }}{% endif %}
{% endblock %}
</title>
{% if page.search_description %}
<meta name="description" content="{{ page.search_description }}" />
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1" />
{# Force all links in the live preview panel to be opened in a new tab #}
{% if request.in_preview_panel %}
<base target="_blank">
{% endif %}
{# Global stylesheets #}
<link rel="stylesheet" type="text/css" href="{% static 'css/skelet.css' %}">
{% block extra_css %}
{# Override this in templates to add extra stylesheets #}
<head>
<meta charset="utf-8" />
<title>
{% block title %}
{% if page.seo_title %}{{ page.seo_title }}{% else %}{{ page.title }}{% endif %}
{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% wagtailuserbar %}
{% block content %}{% endblock %}
{# Global javascript #}
<script type="text/javascript" src="{% static 'js/skelet.js' %}"></script>
{% block extra_js %}
{# Override this in templates to add extra javascript #}
{% block title_suffix %}
{% wagtail_site as current_site %}
{% if current_site and current_site.site_name %}&ndash; {{ current_site.site_name }}{% endif %}
{% endblock %}
</body>
</title>
{% if page.search_description %}
<meta name="description" content="{{ page.search_description }}" />
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1" />
{% if request.in_preview_panel %}
<base target="_blank">
{% endif %}
<link rel="stylesheet" type="text/css" href="{% static 'css/skelet.css' %}">
{% block extra_css %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% wagtailuserbar %}
{% include "includes/nav.html" %}
<main class="site-main">
<div class="container">
{% block content %}{% endblock %}
</div>
</main>
{% include "includes/footer.html" %}
<script type="text/javascript" src="{% static 'js/skelet.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
@@ -0,0 +1,4 @@
<div class="empty-state">
<h2>Nothing here yet</h2>
<p>{{ message|default:"No content available." }}</p>
</div>
+5
View File
@@ -0,0 +1,5 @@
<footer class="site-footer">
<div class="container">
<p>&copy; {% now "Y" %} Skelet. Helping developers find the right project skeleton.</p>
</div>
</footer>
+10
View File
@@ -0,0 +1,10 @@
<nav class="site-nav">
<div class="container">
<a href="/" class="nav-logo">Skelet</a>
<ul class="nav-links">
<li><a href="/scenarios/">Scenarios</a></li>
<li><a href="/projects/">Projects</a></li>
<li><a href="/articles/">Articles</a></li>
</ul>
</div>
</nav>
@@ -0,0 +1 @@
<span class="badge badge-sponsored">Sponsored</span>