diff --git a/AGENTS.md b/AGENTS.md
index 5bc9959..93a9e90 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 基础。
## 开发环境
diff --git a/core/models.py b/core/models.py
index 9ea253a..d590360 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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
diff --git a/core/templates/core/article_index_page.html b/core/templates/core/article_index_page.html
new file mode 100644
index 0000000..ef74df7
--- /dev/null
+++ b/core/templates/core/article_index_page.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
{{ page.title }}
+
+{% if page.get_children.live %}
+
+{% else %}
+ {% include "includes/empty_state.html" with message="No articles yet." %}
+{% endif %}
+
+{% endblock %}
diff --git a/core/templates/core/article_page.html b/core/templates/core/article_page.html
new file mode 100644
index 0000000..5c92f27
--- /dev/null
+++ b/core/templates/core/article_page.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{{ page.title }}
+
+{{ page.body|safe }}
+
+{% if page.related_projects.exists %}
+Related Projects
+
+{% endif %}
+
+{% endblock %}
diff --git a/core/templates/core/project_index_page.html b/core/templates/core/project_index_page.html
new file mode 100644
index 0000000..962ec44
--- /dev/null
+++ b/core/templates/core/project_index_page.html
@@ -0,0 +1,85 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{{ page.title }}
+
+
+
+{% if projects %}
+
+
+ {% if projects.paginator.num_pages > 1 %}
+
+ {% endif %}
+{% else %}
+ {% include "includes/empty_state.html" with message="No projects match your filters." %}
+{% endif %}
+
+{% endblock %}
diff --git a/core/templates/core/scenario_page.html b/core/templates/core/scenario_page.html
new file mode 100644
index 0000000..1e0500d
--- /dev/null
+++ b/core/templates/core/scenario_page.html
@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{{ page.title }}
+
+{% if projects %}
+
+{% else %}
+ {% include "includes/empty_state.html" with message="No projects found in this scenario." %}
+{% endif %}
+
+{% endblock %}
diff --git a/core/templates/core/skeleton_project_page.html b/core/templates/core/skeleton_project_page.html
new file mode 100644
index 0000000..b33c53f
--- /dev/null
+++ b/core/templates/core/skeleton_project_page.html
@@ -0,0 +1,76 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
+ {{ page.title }}
+ {% if page.is_featured %}Featured{% endif %}
+ {% if page.is_sponsored %}{% include "includes/sponsored_badge.html" %}{% endif %}
+
+
+ {{ page.summary }}
+
+
+ | GitHub | {{ page.github_url }} |
+ {% if page.official_url %}
+ | Official Site | {{ page.official_url }} |
+ {% endif %}
+ {% if page.license_name %}
+ | License | {{ page.license_name }} |
+ {% endif %}
+ | Maturity | {{ page.get_maturity_display }} |
+ | Maintenance | {{ page.get_maintenance_status_display }} |
+ {% if page.scenarios.exists %}
+ | Scenarios |
+ {% for s in page.scenarios.all %}{{ s.title }}{% endfor %}
+ |
+ {% endif %}
+ {% if page.languages.exists %}
+ | Languages |
+ {% for l in page.languages.all %}{{ l.name }}{% endfor %}
+ |
+ {% endif %}
+ {% if page.frameworks.exists %}
+ | Frameworks |
+ {% for f in page.frameworks.all %}{{ f.name }}{% endfor %}
+ |
+ {% endif %}
+ {% if page.databases.exists %}
+ | Databases |
+ {% for d in page.databases.all %}{{ d.name }}{% endfor %}
+ |
+ {% endif %}
+ {% if page.features.exists %}
+ | Features |
+ {% for feat in page.features.all %}{{ feat.name }}{% endfor %}
+ |
+ {% endif %}
+
+
+ Scores
+
+ | Structure | {{ page.structure_score }}/5 |
+ | Documentation | {{ page.docs_score }}/5 |
+ | Testing | {{ page.tests_score }}/5 |
+ | Examples | {{ page.example_score }}/5 |
+ | Dependency Control | {{ page.dependency_score }}/5 |
+ | Incremental Development | {{ page.incremental_score }}/5 |
+ | Total | {{ page.total_score }}/30 |
+
+
+ Recommended For
+ {{ page.recommended_for|safe }}
+
+ {% if page.not_recommended_for %}
+ Not Recommended For
+ {{ page.not_recommended_for|safe }}
+ {% endif %}
+
+ {% if page.review_notes %}
+ Review Notes
+ {{ page.review_notes|safe }}
+ {% endif %}
+
+
+{% endblock %}
diff --git a/core/tests.py b/core/tests.py
index 6adde42..062a192 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -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="About the linked project.
",
+ )
+ 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):
diff --git a/docs/06-tasks.md b/docs/06-tasks.md
index 7921e47..8567ff3 100644
--- a/docs/06-tasks.md
+++ b/docs/06-tasks.md
@@ -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 与上线准备
diff --git a/docs/current-state.md b/docs/current-state.md
index 1430256..4e2c278 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -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)。
## 当前可运行内容
diff --git a/home/models.py b/home/models.py
index 5076f57..48cb659 100644
--- a/home/models.py
+++ b/home/models.py
@@ -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
diff --git a/home/templates/home/home_page.html b/home/templates/home/home_page.html
index db9e9b0..f38c5b9 100644
--- a/home/templates/home/home_page.html
+++ b/home/templates/home/home_page.html
@@ -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 %}
-
-{% 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' %}
+Find the Right Project Skeleton
+Browse open-source project skeletons by scenario, language, framework, and AI-coding friendliness score.
+
+{% if scenarios %}
+
+{% endif %}
+
+{% if featured_projects %}
+
+{% endif %}
+
+{% if latest_articles %}
+
+{% endif %}
{% endblock content %}
diff --git a/home/tests.py b/home/tests.py
index a2a58a1..df24fab 100644
--- a/home/tests.py
+++ b/home/tests.py
@@ -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")
\ No newline at end of file
diff --git a/progress.md b/progress.md
index ad01929..d2e2d99 100644
--- a/progress.md
+++ b/progress.md
@@ -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)。
+
diff --git a/skelet/static/css/skelet.css b/skelet/static/css/skelet.css
index e69de29..979bae2 100644
--- a/skelet/static/css/skelet.css
+++ b/skelet/static/css/skelet.css
@@ -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; }
diff --git a/skelet/templates/base.html b/skelet/templates/base.html
index 7c5d20f..e110771 100644
--- a/skelet/templates/base.html
+++ b/skelet/templates/base.html
@@ -2,45 +2,46 @@
-
-
-
- {% 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 %}
-
- {% if page.search_description %}
-
- {% endif %}
-
-
- {# Force all links in the live preview panel to be opened in a new tab #}
- {% if request.in_preview_panel %}
-
- {% endif %}
-
- {# Global stylesheets #}
-
-
- {% block extra_css %}
- {# Override this in templates to add extra stylesheets #}
+
+
+
+ {% block title %}
+ {% if page.seo_title %}{{ page.seo_title }}{% else %}{{ page.title }}{% endif %}
{% endblock %}
-
-
-
- {% wagtailuserbar %}
-
- {% block content %}{% endblock %}
-
- {# Global javascript #}
-
-
- {% 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 %}– {{ current_site.site_name }}{% endif %}
{% endblock %}
-
+
+ {% if page.search_description %}
+
+ {% endif %}
+
+
+ {% if request.in_preview_panel %}
+
+ {% endif %}
+
+
+
+ {% block extra_css %}{% endblock %}
+
+
+
+ {% wagtailuserbar %}
+
+ {% include "includes/nav.html" %}
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+ {% include "includes/footer.html" %}
+
+
+
+ {% block extra_js %}{% endblock %}
+
diff --git a/skelet/templates/includes/empty_state.html b/skelet/templates/includes/empty_state.html
new file mode 100644
index 0000000..432209e
--- /dev/null
+++ b/skelet/templates/includes/empty_state.html
@@ -0,0 +1,4 @@
+
+
Nothing here yet
+
{{ message|default:"No content available." }}
+
diff --git a/skelet/templates/includes/footer.html b/skelet/templates/includes/footer.html
new file mode 100644
index 0000000..8df1b0d
--- /dev/null
+++ b/skelet/templates/includes/footer.html
@@ -0,0 +1,5 @@
+
diff --git a/skelet/templates/includes/nav.html b/skelet/templates/includes/nav.html
new file mode 100644
index 0000000..2e16134
--- /dev/null
+++ b/skelet/templates/includes/nav.html
@@ -0,0 +1,10 @@
+
diff --git a/skelet/templates/includes/sponsored_badge.html b/skelet/templates/includes/sponsored_badge.html
new file mode 100644
index 0000000..f3b7141
--- /dev/null
+++ b/skelet/templates/includes/sponsored_badge.html
@@ -0,0 +1 @@
+