commit c90b10cd5a980b51c3a6b1885512381d247d5da8
Author: flowerstonezl <125468358+flowerstonezl@users.noreply.github.com>
Date: Mon May 11 19:34:35 2026 +0800
first commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ab23fa8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+backend/.venv/
+backend/__pycache__/
+backend/**/__pycache__/
+backend/db.sqlite3
+backend/media/
+backend/staticfiles/
+frontend/node_modules/
+frontend/dist/
+*.log
+.DS_Store
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..e6e22c3
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,7 @@
+.venv
+__pycache__
+*.py[cod]
+db.sqlite3
+media
+staticfiles
+.git
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..ab72762
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,25 @@
+FROM python:3.12-slim-bookworm
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+ENV DJANGO_DEBUG=false
+ENV SQLITE_PATH=/data/db.sqlite3
+ENV MEDIA_ROOT=/data/media
+ENV DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
+ENV CORS_ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libjpeg62-turbo zlib1g \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+RUN chmod +x /app/docker-entrypoint.sh
+
+EXPOSE 8000
+
+ENTRYPOINT ["/app/docker-entrypoint.sh"]
diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh
new file mode 100644
index 0000000..f3abdb0
--- /dev/null
+++ b/backend/docker-entrypoint.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -e
+mkdir -p "$(dirname "${SQLITE_PATH:-/data/db.sqlite3}")" "${MEDIA_ROOT:-/data/media}"
+python manage.py migrate --noinput
+python manage.py collectstatic --noinput
+exec gunicorn ourstory.wsgi:application --bind 0.0.0.0:8000 --workers 2
diff --git a/backend/journal/__init__.py b/backend/journal/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/journal/admin.py b/backend/journal/admin.py
new file mode 100644
index 0000000..79d9121
--- /dev/null
+++ b/backend/journal/admin.py
@@ -0,0 +1,196 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from django.utils.safestring import mark_safe
+
+from .models import Config, Guestbook, Memory, MemoryPhoto
+
+
+class JournalStaffAccessMixin:
+ """
+ 手账站点多为小项目:凡 is_staff 即可在后台增删改查(无需在「用户」里逐项勾选 journal.* 权限)。
+ 生产环境若需细分权限,可移除此 Mixin 并改回 Django 默认的 has_perm 校验。
+ """
+
+ def has_module_permission(self, request):
+ u = request.user
+ return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
+
+ def has_view_permission(self, request, obj=None):
+ u = request.user
+ return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
+
+ def has_change_permission(self, request, obj=None):
+ u = request.user
+ return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
+
+ def has_add_permission(self, request):
+ u = request.user
+ return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
+
+ def has_delete_permission(self, request, obj=None):
+ u = request.user
+ return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
+
+
+class MemoryPhotoInline(admin.TabularInline):
+ model = MemoryPhoto
+ extra = 1
+ fields = ("image", "sort_order")
+
+
+@admin.register(Memory)
+class MemoryAdmin(JournalStaffAccessMixin, admin.ModelAdmin):
+ inlines = (MemoryPhotoInline,)
+ list_display = (
+ "event_title",
+ "story_order",
+ "date",
+ "unique_feature_type",
+ "nepal_art_style",
+ "is_finale",
+ "thumb_preview",
+ )
+ list_display_links = ("event_title",)
+ list_filter = ("unique_feature_type", "nepal_art_style", "is_finale", "date")
+ list_editable = ("story_order",)
+ search_fields = ("event_title", "main_text")
+ readonly_fields = ("image_preview",)
+ ordering = ("story_order", "id")
+
+ fieldsets = (
+ (
+ "叙事",
+ {
+ "fields": (
+ "story_order",
+ "date",
+ "event_title",
+ "is_finale",
+ )
+ },
+ ),
+ (
+ "版式",
+ {
+ "fields": (
+ "unique_feature_type",
+ "nepal_art_style",
+ "image",
+ "image_preview",
+ "main_text",
+ )
+ },
+ ),
+ )
+
+ @admin.display(description="缩略图")
+ def thumb_preview(self, obj: Memory) -> str:
+ url = None
+ if obj.image:
+ url = obj.image.url
+ else:
+ first = obj.photos.order_by("sort_order", "id").first()
+ if first:
+ url = first.image.url
+ if not url:
+ return "—"
+ return format_html(
+ '
',
+ url,
+ )
+
+ @admin.display(description="配图预览")
+ def image_preview(self, obj: Memory) -> str:
+ chunks: list[str] = []
+ if obj.image:
+ chunks.append(
+ format_html(
+ '
主图'
+ '

',
+ obj.image.url,
+ )
+ )
+ for p in obj.photos.order_by("sort_order", "id"):
+ chunks.append(
+ format_html(
+ '
',
+ p.image.url,
+ )
+ )
+ if not chunks:
+ return "—"
+ return mark_safe("".join(str(c) for c in chunks))
+
+
+@admin.register(Guestbook)
+class GuestbookAdmin(JournalStaffAccessMixin, admin.ModelAdmin):
+ list_display = ("nickname", "short_message", "created_at")
+ search_fields = ("nickname", "message")
+ readonly_fields = ("created_at",)
+
+ @admin.display(description="留言")
+ def short_message(self, obj: Guestbook) -> str:
+ return obj.message[:80] + ("…" if len(obj.message) > 80 else "")
+
+
+@admin.register(Config)
+class ConfigAdmin(admin.ModelAdmin):
+ """
+ 站点配置单独写权限:不继承 JournalStaffAccessMixin,避免 MRO/多重继承下
+ has_change_permission 未按预期生效,导致 change 页全部为只读且 submit_row 不显示「保存」。
+ """
+
+ save_on_top = True
+
+ list_display = (
+ "site_title",
+ "together_since",
+ "bgm_enabled",
+ "unlock_at",
+ )
+ list_display_links = ("site_title",)
+ fieldsets = (
+ (
+ "站点",
+ {"fields": ("site_title",)},
+ ),
+ (
+ "时间与音乐",
+ {
+ "fields": (
+ "together_since",
+ "bgm_enabled",
+ "unlock_at",
+ ),
+ "description": (
+ "「在一起」时间用于首页计数。顶部网易云播放器在前端 .env 的 "
+ "VITE_NETEASE_PLAYER_SRC 配置。unlock_at 已不使用,可留空。"
+ ),
+ },
+ ),
+ )
+
+ def _staff_ok(self, request) -> bool:
+ u = request.user
+ return bool(
+ getattr(u, "is_active", False)
+ and getattr(u, "is_authenticated", False)
+ and (getattr(u, "is_staff", False) or getattr(u, "is_superuser", False))
+ )
+
+ def has_module_permission(self, request):
+ return self._staff_ok(request)
+
+ def has_view_permission(self, request, obj=None):
+ return self._staff_ok(request)
+
+ def has_change_permission(self, request, obj=None):
+ return self._staff_ok(request)
+
+ def has_add_permission(self, request):
+ if not self._staff_ok(request):
+ return False
+ return not Config.objects.exists()
+
+ def has_delete_permission(self, request, obj=None):
+ return False
diff --git a/backend/journal/apps.py b/backend/journal/apps.py
new file mode 100644
index 0000000..afe76cb
--- /dev/null
+++ b/backend/journal/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class JournalConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'journal'
diff --git a/backend/journal/management/__init__.py b/backend/journal/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/journal/management/commands/__init__.py b/backend/journal/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/journal/management/commands/seed_nepal_story.py b/backend/journal/management/commands/seed_nepal_story.py
new file mode 100644
index 0000000..e5b2cf0
--- /dev/null
+++ b/backend/journal/management/commands/seed_nepal_story.py
@@ -0,0 +1,85 @@
+from datetime import date
+
+from django.core.management.base import BaseCommand
+
+from journal.models import Memory
+
+
+class Command(BaseCommand):
+ help = "Seed 2026 春季时间线:遇见→争执→和好→租房→宜家,终章心窗。"
+
+ def handle(self, *args, **options):
+ Memory.objects.all().delete()
+
+ Memory.objects.create(
+ story_order=10,
+ date=date(2026, 4, 20),
+ event_title="第一次遇见你",
+ main_text=(
+ "**2026 年 4 月 20 日**,故事从这里开始。\n\n"
+ "相识、相见、相遇——我把这一页留给那天的风与心跳。"
+ ),
+ unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
+ nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=20,
+ date=date(2026, 5, 3),
+ event_title="那天我们吵架了",
+ main_text="旁人皆三千江水,唯你是心上涟漪。",
+ unique_feature_type=Memory.FeatureType.FISSURE_PAGE,
+ nepal_art_style=Memory.NepalArtStyle.BLUSH_ARGUE,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=30,
+ date=date(2026, 5, 4),
+ event_title="和好了,在一起吧",
+ main_text=(
+ "**5 月 4 日**:别扭说开以后,空气又变甜了。\n\n"
+ "谢谢你愿意和我把话说完。"
+ ),
+ unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
+ nepal_art_style=Memory.NepalArtStyle.MINT_RECONCILE,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=40,
+ date=date(2026, 5, 8),
+ event_title="一起租了房",
+ main_text=(
+ "钥匙圈上的小叮当,和阳台晒进来的光。\n\n"
+ "> 这是我们共同的小宇宙开始成形的日子。"
+ ),
+ unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
+ nepal_art_style=Memory.NepalArtStyle.SAFFRON_HOME,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=50,
+ date=date(2026, 5, 10),
+ event_title="逛宜家的小日子",
+ main_text=(
+ "推车里的台灯、地毯和一杯热狗,"
+ "都是以后会说「还记得吗」的碎片。"
+ ),
+ unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
+ nepal_art_style=Memory.NepalArtStyle.INDIGO_IKEA,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=90,
+ date=date(2026, 5, 20),
+ event_title="心墙与窗 · 终章",
+ main_text=(
+ "不开心就打开心墙。\n\n"
+ "**我会在你身边。**\n\n"
+ "—— *open when you need me*"
+ ),
+ unique_feature_type=Memory.FeatureType.HEART_WINDOW,
+ nepal_art_style=Memory.NepalArtStyle.LOTUS_PETAL,
+ is_finale=True,
+ )
+
+ self.stdout.write(self.style.SUCCESS("Seeded %s memories." % Memory.objects.count()))
diff --git a/backend/journal/migrations/0001_initial.py b/backend/journal/migrations/0001_initial.py
new file mode 100644
index 0000000..d2f27a6
--- /dev/null
+++ b/backend/journal/migrations/0001_initial.py
@@ -0,0 +1,56 @@
+# Generated by Django 5.2.14 on 2026-05-11 10:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Config',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('unlock_at', models.DateTimeField(help_text='Story unlocks at this time (timezone-aware).')),
+ ('bgm_enabled', models.BooleanField(default=True)),
+ ('site_title', models.CharField(blank=True, default='Nepal Journey: Our Story', max_length=120)),
+ ],
+ options={
+ 'verbose_name_plural': 'Config',
+ },
+ ),
+ migrations.CreateModel(
+ name='Guestbook',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('nickname', models.CharField(max_length=80)),
+ ('message', models.TextField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ],
+ options={
+ 'verbose_name_plural': 'Guestbook entries',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Memory',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('story_order', models.PositiveIntegerField(db_index=True, default=0, help_text='叙事顺序,数字越小越靠前。')),
+ ('date', models.DateField()),
+ ('event_title', models.CharField(max_length=200)),
+ ('main_text', models.TextField(blank=True, help_text='支持 Markdown。')),
+ ('unique_feature_type', models.CharField(choices=[('heart_window', '心窗页'), ('fissure_page', '裂缝对开页'), ('standard_nepal', '标准尼泊尔页')], default='standard_nepal', max_length=32)),
+ ('nepal_art_style', models.CharField(blank=True, choices=[('none', '默认'), ('mandala_pink', '粉曼陀罗'), ('mandala_blue', '蓝曼陀罗'), ('spiral_rose', '玫瑰螺旋'), ('spiral_cyan', '青色螺旋')], default='none', max_length=32)),
+ ('image', models.ImageField(blank=True, null=True, upload_to='memories/')),
+ ('is_finale', models.BooleanField(default=False, help_text='终章:在解锁时间前仅可预览占位,到点后需手动打开。')),
+ ],
+ options={
+ 'ordering': ['story_order', 'id'],
+ },
+ ),
+ ]
diff --git a/backend/journal/migrations/0002_expand_nepal_art_style_length.py b/backend/journal/migrations/0002_expand_nepal_art_style_length.py
new file mode 100644
index 0000000..62e13cd
--- /dev/null
+++ b/backend/journal/migrations/0002_expand_nepal_art_style_length.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.14 on 2026-05-11 10:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('journal', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='memory',
+ name='nepal_art_style',
+ field=models.CharField(blank=True, choices=[('none', '默认米黄'), ('mandala_pink', '粉曼陀罗'), ('mandala_blue', '蓝曼陀罗'), ('spiral_rose', '玫瑰螺旋'), ('spiral_cyan', '青色螺旋'), ('dawn_meet', '晨光·遇见'), ('blush_argue', '粉雾·争执'), ('mint_reconcile', '薄荷·和好'), ('saffron_home', '姜黄·小家'), ('indigo_ikea', '靛蓝·漫游'), ('lotus_petal', '莲花瓣'), ('lokta_gold', '金边手工纸'), ('ocean_prayer', '海洋祈愿'), ('desert_rose', '沙漠玫瑰'), ('night_star', '夜空星屑'), ('terra_torn', '赭石撕边')], default='none', help_text='标准页背景/纹理;心窗与裂缝页也可作辅色参考。', max_length=48),
+ ),
+ ]
diff --git a/backend/journal/migrations/0003_config_together_netease_unlock_nullable.py b/backend/journal/migrations/0003_config_together_netease_unlock_nullable.py
new file mode 100644
index 0000000..d34b8ea
--- /dev/null
+++ b/backend/journal/migrations/0003_config_together_netease_unlock_nullable.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.14 on 2026-05-11 10:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('journal', '0002_expand_nepal_art_style_length'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='config',
+ name='netease_player_url',
+ field=models.URLField(blank=True, help_text='网易云「外链播放器」生成的地址,须以 https://music.163.com/ 开头。留空则不显示播放器。', max_length=512),
+ ),
+ migrations.AddField(
+ model_name='config',
+ name='together_since',
+ field=models.DateTimeField(blank=True, help_text='用于首页「我们在一起多少天多少秒」的起点(建议为正式在一起的时刻,含时区)。', null=True),
+ ),
+ migrations.AlterField(
+ model_name='config',
+ name='bgm_enabled',
+ field=models.BooleanField(default=True, help_text='是否展示顶部音乐区(网易云 iframe)。'),
+ ),
+ migrations.AlterField(
+ model_name='config',
+ name='unlock_at',
+ field=models.DateTimeField(blank=True, help_text='已弃用:前端不再按此锁定章节,可留空。', null=True),
+ ),
+ ]
diff --git a/backend/journal/migrations/0004_netease_player_textfield.py b/backend/journal/migrations/0004_netease_player_textfield.py
new file mode 100644
index 0000000..cadc814
--- /dev/null
+++ b/backend/journal/migrations/0004_netease_player_textfield.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.14 on 2026-05-11 10:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('journal', '0003_config_together_netease_unlock_nullable'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='config',
+ name='netease_player_url',
+ field=models.TextField(blank=True, help_text='粘贴网易云外链播放器的整段