From cff3c64c19ea389b091fa7c76f5eac53148bc7d8 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Mon, 6 Jul 2026 22:57:08 +0800 Subject: [PATCH] =?UTF-8?q?Phase=202:=20T-201=20=E8=87=B3=20T-206=20?= =?UTF-8?q?=E5=89=8D=E5=8F=B0=20MVP=20=E5=85=A8=E9=83=A8=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=EF=BC=8811=20templates=20+=20CSS=20+=2031=20tests=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- core/models.py | 70 +++++ core/templates/core/article_index_page.html | 19 ++ core/templates/core/article_page.html | 21 ++ core/templates/core/project_index_page.html | 85 ++++++ core/templates/core/scenario_page.html | 29 ++ .../templates/core/skeleton_project_page.html | 76 +++++ core/tests.py | 217 +++++++++++-- docs/06-tasks.md | 12 +- docs/current-state.md | 8 +- home/models.py | 19 +- home/templates/home/home_page.html | 69 ++++- home/tests.py | 68 ++++- progress.md | 10 +- skelet/static/css/skelet.css | 288 ++++++++++++++++++ skelet/templates/base.html | 79 ++--- skelet/templates/includes/empty_state.html | 4 + skelet/templates/includes/footer.html | 5 + skelet/templates/includes/nav.html | 10 + .../templates/includes/sponsored_badge.html | 1 + 20 files changed, 996 insertions(+), 98 deletions(-) create mode 100644 core/templates/core/article_index_page.html create mode 100644 core/templates/core/article_page.html create mode 100644 core/templates/core/project_index_page.html create mode 100644 core/templates/core/scenario_page.html create mode 100644 core/templates/core/skeleton_project_page.html create mode 100644 skelet/templates/includes/empty_state.html create mode 100644 skelet/templates/includes/footer.html create mode 100644 skelet/templates/includes/nav.html create mode 100644 skelet/templates/includes/sponsored_badge.html 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 %} +
+ {% for article in page.get_children.live %} + +

{{ article.title }}

+
+ {% endfor %} +
+{% 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

+
+ {% for project in page.related_projects.all %} + +

{{ project.title }}

+

{{ project.summary|truncatewords:20 }}

+
+ {% endfor %} +
+{% 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 %} +
+ {% for project in projects %} + +

{{ project.title }}

+

{{ project.summary|truncatewords:20 }}

+
+ {{ project.total_score }}/30 + AI-Friendly Score +
+
+ {% for lang in project.languages.all|slice:":3" %} + {{ lang.name }} + {% endfor %} +
+
+ {% endfor %} +
+ + {% 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 %} +
+ {% for project in projects %} + +

{{ project.title }}

+

{{ project.summary|truncatewords:20 }}

+
+ {{ project.total_score }}/30 + AI-Friendly Score +
+
+ {% for lang in project.languages.all|slice:":3" %} + {{ lang.name }} + {% endfor %} +
+
+ {% endfor %} +
+{% 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 }}

+ + + + {% if page.official_url %} + + {% endif %} + {% if page.license_name %} + + {% endif %} + + + {% if page.scenarios.exists %} + + {% endif %} + {% if page.languages.exists %} + + {% endif %} + {% if page.frameworks.exists %} + + {% endif %} + {% if page.databases.exists %} + + {% endif %} + {% if page.features.exists %} + + {% endif %} +
GitHub{{ page.github_url }}
Official Site{{ page.official_url }}
License{{ page.license_name }}
Maturity{{ page.get_maturity_display }}
Maintenance{{ page.get_maintenance_status_display }}
Scenarios + {% for s in page.scenarios.all %}{{ s.title }}{% endfor %} +
Languages + {% for l in page.languages.all %}{{ l.name }}{% endfor %} +
Frameworks + {% for f in page.frameworks.all %}{{ f.name }}{% endfor %} +
Databases + {% for d in page.databases.all %}{{ d.name }}{% endfor %} +
Features + {% for feat in page.features.all %}{{ feat.name }}{% endfor %} +
+ +

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 %} +
+

Scenarios

+
+ {% for scenario in scenarios %} + +

{{ scenario.title }}

+
+ {% endfor %} +
+
+{% endif %} + +{% if featured_projects %} +
+

Featured Projects

+ +
+{% endif %} + +{% if latest_articles %} +
+

Latest Articles

+
+ {% for article in latest_articles %} + +

{{ article.title }}

+
+ {% endfor %} +
+
+{% 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 %} - </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 %}– {{ current_site.site_name }}{% endif %} {% endblock %} - </body> + + {% 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 @@ +Sponsored