first commit
This commit is contained in:
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
db.sqlite3
|
||||
media
|
||||
staticfiles
|
||||
.git
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -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"]
|
||||
6
backend/docker-entrypoint.sh
Normal file
6
backend/docker-entrypoint.sh
Normal file
@@ -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
|
||||
0
backend/journal/__init__.py
Normal file
0
backend/journal/__init__.py
Normal file
196
backend/journal/admin.py
Normal file
196
backend/journal/admin.py
Normal file
@@ -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(
|
||||
'<img src="{}" style="max-height:48px;border-radius:4px;" />',
|
||||
url,
|
||||
)
|
||||
|
||||
@admin.display(description="配图预览")
|
||||
def image_preview(self, obj: Memory) -> str:
|
||||
chunks: list[str] = []
|
||||
if obj.image:
|
||||
chunks.append(
|
||||
format_html(
|
||||
'<div class="mb-2"><span style="font-size:11px;color:#666">主图</span><br>'
|
||||
'<img src="{}" style="max-width:280px;border-radius:8px;" /></div>',
|
||||
obj.image.url,
|
||||
)
|
||||
)
|
||||
for p in obj.photos.order_by("sort_order", "id"):
|
||||
chunks.append(
|
||||
format_html(
|
||||
'<img src="{}" style="max-width:200px;border-radius:6px;margin:4px;" />',
|
||||
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
|
||||
6
backend/journal/apps.py
Normal file
6
backend/journal/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class JournalConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'journal'
|
||||
0
backend/journal/management/__init__.py
Normal file
0
backend/journal/management/__init__.py
Normal file
0
backend/journal/management/commands/__init__.py
Normal file
0
backend/journal/management/commands/__init__.py
Normal file
85
backend/journal/management/commands/seed_nepal_story.py
Normal file
85
backend/journal/management/commands/seed_nepal_story.py
Normal file
@@ -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()))
|
||||
56
backend/journal/migrations/0001_initial.py
Normal file
56
backend/journal/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
18
backend/journal/migrations/0004_netease_player_textfield.py
Normal file
18
backend/journal/migrations/0004_netease_player_textfield.py
Normal file
@@ -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='粘贴网易云外链播放器的整段 <iframe …>,或只粘贴 src 里的链接(支持 //music.163.com/…)。留空则不显示播放器。'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 11:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0004_netease_player_textfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='config',
|
||||
name='netease_player_url',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='config',
|
||||
name='bgm_enabled',
|
||||
field=models.BooleanField(default=True, help_text='是否展示顶部音乐区(播放器地址在前端环境变量 VITE_NETEASE_PLAYER_SRC 配置)。'),
|
||||
),
|
||||
]
|
||||
28
backend/journal/migrations/0006_memory_photo.py
Normal file
28
backend/journal/migrations/0006_memory_photo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 11:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0005_remove_netease_from_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MemoryPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='memory_photos/')),
|
||||
('sort_order', models.PositiveSmallIntegerField(default=0, help_text='数字越小越靠前(排在主图之后)。')),
|
||||
('memory', models.ForeignKey(help_text='所属故事页', on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='journal.memory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '附加照片',
|
||||
'verbose_name_plural': '附加照片',
|
||||
'ordering': ['sort_order', 'id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/journal/migrations/__init__.py
Normal file
0
backend/journal/migrations/__init__.py
Normal file
143
backend/journal/models.py
Normal file
143
backend/journal/models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Memory(models.Model):
|
||||
"""恋爱故事页,驱动前端尼泊尔本版式。"""
|
||||
|
||||
class FeatureType(models.TextChoices):
|
||||
HEART_WINDOW = "heart_window", "心窗页"
|
||||
FISSURE_PAGE = "fissure_page", "裂缝对开页"
|
||||
STANDARD_NEPAL = "standard_nepal", "标准尼泊尔页"
|
||||
|
||||
class NepalArtStyle(models.TextChoices):
|
||||
"""每页可选不同尼泊尔视觉皮肤(备用可继续加枚举)。"""
|
||||
NONE = "none", "默认米黄"
|
||||
MANDALA_PINK = "mandala_pink", "粉曼陀罗"
|
||||
MANDALA_BLUE = "mandala_blue", "蓝曼陀罗"
|
||||
SPIRAL_ROSE = "spiral_rose", "玫瑰螺旋"
|
||||
SPIRAL_CYAN = "spiral_cyan", "青色螺旋"
|
||||
DAWN_MEET = "dawn_meet", "晨光·遇见"
|
||||
BLUSH_ARGUE = "blush_argue", "粉雾·争执"
|
||||
MINT_RECONCILE = "mint_reconcile", "薄荷·和好"
|
||||
SAFFRON_HOME = "saffron_home", "姜黄·小家"
|
||||
INDIGO_IKEA = "indigo_ikea", "靛蓝·漫游"
|
||||
LOTUS_PETAL = "lotus_petal", "莲花瓣"
|
||||
LOKTA_GOLD = "lokta_gold", "金边手工纸"
|
||||
OCEAN_PRAYER = "ocean_prayer", "海洋祈愿"
|
||||
DESERT_ROSE = "desert_rose", "沙漠玫瑰"
|
||||
NIGHT_STAR = "night_star", "夜空星屑"
|
||||
TERRA_TORN = "terra_torn", "赭石撕边"
|
||||
|
||||
story_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
db_index=True,
|
||||
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(
|
||||
max_length=32,
|
||||
choices=FeatureType.choices,
|
||||
default=FeatureType.STANDARD_NEPAL,
|
||||
)
|
||||
nepal_art_style = models.CharField(
|
||||
max_length=48,
|
||||
choices=NepalArtStyle.choices,
|
||||
default=NepalArtStyle.NONE,
|
||||
blank=True,
|
||||
help_text="标准页背景/纹理;心窗与裂缝页也可作辅色参考。",
|
||||
)
|
||||
image = models.ImageField(upload_to="memories/", blank=True, null=True)
|
||||
is_finale = models.BooleanField(
|
||||
default=False,
|
||||
help_text="终章:在解锁时间前仅可预览占位,到点后需手动打开。",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["story_order", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.story_order}. {self.event_title}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.is_finale:
|
||||
Memory.objects.exclude(pk=self.pk).update(is_finale=False)
|
||||
|
||||
|
||||
class MemoryPhoto(models.Model):
|
||||
"""同一回忆页可附加多张照片;与主图 `Memory.image` 一并展示,主图在前。"""
|
||||
|
||||
memory = models.ForeignKey(
|
||||
Memory,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="photos",
|
||||
help_text="所属故事页",
|
||||
)
|
||||
image = models.ImageField(upload_to="memory_photos/")
|
||||
sort_order = models.PositiveSmallIntegerField(
|
||||
default=0,
|
||||
help_text="数字越小越靠前(排在主图之后)。",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_order", "id"]
|
||||
verbose_name = "附加照片"
|
||||
verbose_name_plural = "附加照片"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.memory_id}·{self.sort_order}"
|
||||
|
||||
|
||||
class Guestbook(models.Model):
|
||||
nickname = models.CharField(max_length=80)
|
||||
message = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name_plural = "Guestbook entries"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.nickname}: {self.message[:40]}"
|
||||
|
||||
|
||||
class Config(models.Model):
|
||||
"""Singleton-style global配置(Admin 中 id=1)。"""
|
||||
|
||||
unlock_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="已弃用:前端不再按此锁定章节,可留空。",
|
||||
)
|
||||
together_since = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="用于首页「我们在一起多少天多少秒」的起点(建议为正式在一起的时刻,含时区)。",
|
||||
)
|
||||
bgm_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="是否展示顶部音乐区(播放器地址在前端环境变量 VITE_NETEASE_PLAYER_SRC 配置)。",
|
||||
)
|
||||
site_title = models.CharField(
|
||||
max_length=120,
|
||||
default="Nepal Journey: Our Story",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Config"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Site configuration"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass
|
||||
51
backend/journal/serializers.py
Normal file
51
backend/journal/serializers.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Guestbook, Memory
|
||||
|
||||
|
||||
def _absolute_uri(request, path: str) -> str:
|
||||
if request is not None:
|
||||
return request.build_absolute_uri(path)
|
||||
return path
|
||||
|
||||
|
||||
class MemorySerializer(serializers.ModelSerializer):
|
||||
"""`image` 为首张 URL(兼容旧前端);`images` 为主图 + 附加图完整列表。"""
|
||||
|
||||
image = serializers.SerializerMethodField()
|
||||
images = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Memory
|
||||
fields = (
|
||||
"id",
|
||||
"story_order",
|
||||
"date",
|
||||
"event_title",
|
||||
"main_text",
|
||||
"unique_feature_type",
|
||||
"nepal_art_style",
|
||||
"image",
|
||||
"images",
|
||||
"is_finale",
|
||||
)
|
||||
|
||||
def get_images(self, obj: Memory) -> list[str]:
|
||||
request = self.context.get("request")
|
||||
out: list[str] = []
|
||||
if obj.image:
|
||||
out.append(_absolute_uri(request, obj.image.url))
|
||||
for p in obj.photos.all():
|
||||
out.append(_absolute_uri(request, p.image.url))
|
||||
return out
|
||||
|
||||
def get_image(self, obj: Memory) -> str | None:
|
||||
imgs = self.get_images(obj)
|
||||
return imgs[0] if imgs else None
|
||||
|
||||
|
||||
class GuestbookCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Guestbook
|
||||
fields = ("id", "nickname", "message", "created_at")
|
||||
read_only_fields = ("id", "created_at")
|
||||
3
backend/journal/tests.py
Normal file
3
backend/journal/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
4
backend/journal/url
Normal file
4
backend/journal/url
Normal file
@@ -0,0 +1,4 @@
|
||||
手账站:http://localhost:8080
|
||||
Admin:http://localhost:8000/admin/
|
||||
|
||||
http://localhost:5173/
|
||||
9
backend/journal/urls.py
Normal file
9
backend/journal/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import GuestbookCreateView, MemoryListView, StatusView
|
||||
|
||||
urlpatterns = [
|
||||
path("memories/", MemoryListView.as_view(), name="memory-list"),
|
||||
path("messages/", GuestbookCreateView.as_view(), name="guestbook-create"),
|
||||
path("status/", StatusView.as_view(), name="status"),
|
||||
]
|
||||
69
backend/journal/views.py
Normal file
69
backend/journal/views.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.generics import CreateAPIView, ListAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import Config, Guestbook, Memory
|
||||
from .serializers import GuestbookCreateSerializer, MemorySerializer
|
||||
|
||||
|
||||
class MemoryListView(ListAPIView):
|
||||
serializer_class = MemorySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Memory.objects.all()
|
||||
.order_by("story_order", "id")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
|
||||
class GuestbookCreateView(CreateAPIView):
|
||||
queryset = Guestbook.objects.all()
|
||||
serializer_class = GuestbookCreateSerializer
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
shanghai = ZoneInfo("Asia/Shanghai")
|
||||
now = timezone.now()
|
||||
|
||||
default_together = datetime(2026, 5, 4, 0, 0, 0, tzinfo=shanghai)
|
||||
|
||||
config = Config.objects.filter(pk=1).first()
|
||||
if config is None:
|
||||
bgm_enabled = True
|
||||
site_title = "Nepal Journey: Our Story"
|
||||
together_since = default_together
|
||||
unlock_at = None
|
||||
else:
|
||||
bgm_enabled = config.bgm_enabled
|
||||
site_title = config.site_title or "Nepal Journey: Our Story"
|
||||
ts = config.together_since
|
||||
if ts is None:
|
||||
together_since = default_together
|
||||
else:
|
||||
together_since = (
|
||||
timezone.make_aware(ts, shanghai) if timezone.is_naive(ts) else ts
|
||||
)
|
||||
ua = config.unlock_at
|
||||
if ua is None:
|
||||
unlock_at = None
|
||||
else:
|
||||
unlock_at = (
|
||||
timezone.make_aware(ua, shanghai) if timezone.is_naive(ua) else ua
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"server_time": now,
|
||||
"unlocked": True,
|
||||
"unlock_at": unlock_at,
|
||||
"together_since": together_since,
|
||||
"bgm_enabled": bgm_enabled,
|
||||
"site_title": site_title,
|
||||
}
|
||||
)
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
backend/ourstory/__init__.py
Normal file
0
backend/ourstory/__init__.py
Normal file
16
backend/ourstory/asgi.py
Normal file
16
backend/ourstory/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for ourstory project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
139
backend/ourstory/settings.py
Normal file
139
backend/ourstory/settings.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Django settings for ourstory project.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get(
|
||||
"DJANGO_SECRET_KEY",
|
||||
"django-insecure-dev-only-change-in-production",
|
||||
)
|
||||
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
h.strip()
|
||||
for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
if h.strip()
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"journal",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "ourstory.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "ourstory.wsgi.application"
|
||||
|
||||
_sqlite_path = os.environ.get("SQLITE_PATH")
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": _sqlite_path if _sqlite_path else BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "zh-hans"
|
||||
|
||||
TIME_ZONE = "Asia/Shanghai"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
_media_root = os.environ.get("MEDIA_ROOT")
|
||||
MEDIA_ROOT = Path(_media_root) if _media_root else BASE_DIR / "media"
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
"OPTIONS": {
|
||||
"location": str(MEDIA_ROOT),
|
||||
"base_url": MEDIA_URL,
|
||||
},
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.AllowAny",
|
||||
],
|
||||
}
|
||||
|
||||
_cors_origins = os.environ.get(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173",
|
||||
)
|
||||
CORS_ALLOWED_ORIGINS = [o.strip() for o in _cors_origins.split(",") if o.strip()]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
o.strip() for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o.strip()
|
||||
]
|
||||
# 未配置时至少信任本机 Admin,避免部分环境下保存表单被 CSRF 拦截
|
||||
if not CSRF_TRUSTED_ORIGINS:
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://127.0.0.1:8000",
|
||||
"http://localhost:8000",
|
||||
]
|
||||
14
backend/ourstory/urls.py
Normal file
14
backend/ourstory/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.static import serve
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("journal.urls")),
|
||||
re_path(
|
||||
r"^media/(?P<path>.*)$",
|
||||
serve,
|
||||
{"document_root": settings.MEDIA_ROOT},
|
||||
),
|
||||
]
|
||||
16
backend/ourstory/wsgi.py
Normal file
16
backend/ourstory/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for ourstory project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Django>=5.0,<6
|
||||
djangorestframework>=3.15
|
||||
django-cors-headers>=4.6
|
||||
Pillow>=11.0
|
||||
gunicorn>=23.0
|
||||
whitenoise>=6.8
|
||||
Reference in New Issue
Block a user