first commit

This commit is contained in:
flowerstonezl
2026-05-11 19:34:35 +08:00
commit c90b10cd5a
89 changed files with 6142 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -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

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.venv
__pycache__
*.py[cod]
db.sqlite3
media
staticfiles
.git

25
backend/Dockerfile Normal file
View 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"]

View 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

View File

196
backend/journal/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class JournalConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'journal'

View File

View 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()))

View 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'],
},
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View 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/…)。留空则不显示播放器。'),
),
]

View File

@@ -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 配置)。'),
),
]

View 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'],
},
),
]

View File

143
backend/journal/models.py Normal file
View 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

View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

4
backend/journal/url Normal file
View File

@@ -0,0 +1,4 @@
手账站http://localhost:8080
Adminhttp://localhost:8000/admin/
http://localhost:5173/

9
backend/journal/urls.py Normal file
View 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
View 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
View 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()

View File

16
backend/ourstory/asgi.py Normal file
View 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()

View 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
View 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
View 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
View 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

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
# 一键启动docker compose up --build
# 前端 http://localhost:8080 后端管理 http://localhost:8000/admin/
services:
backend:
build: ./backend
ports:
- "8000:8000"
volumes:
- backend_data:/data
environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-secret-change-me}
DJANGO_DEBUG: "false"
DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,backend
CORS_ALLOWED_ORIGINS: http://localhost:8080,http://127.0.0.1:8080
CSRF_TRUSTED_ORIGINS: http://localhost:8080,http://127.0.0.1:8080
SQLITE_PATH: /data/db.sqlite3
MEDIA_ROOT: /data/media
frontend:
build: ./frontend
ports:
- "8080:80"
depends_on:
- backend
volumes:
backend_data:

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

View File

@@ -0,0 +1,5 @@
# 使用 Vite 代理时保持默认 /api 即可
VITE_API_BASE_URL=/api
# 网易云外链播放器 src整段 iframe 亦可,会自动提取 src
# VITE_NETEASE_PLAYER_SRC=https://music.163.com/outchain/player?type=2&id=2054353432&auto=1&height=66

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

17
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV VITE_API_BASE_URL=/api
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

20
frontend/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#faf6ef" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;600&family=Dancing+Script:wght@500;700&family=Great+Vibes&family=Long+Cang&family=Ma+Shan+Zheng&family=Nunito:wght@400;600;700&family=Patrick+Hand&family=ZCOOL+QingKe+HuangYou&family=Zhi+Mang+Xing&display=swap"
rel="stylesheet"
/>
<title>Nepal Journey: Our Story</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

31
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /media/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

2263
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.3.0",
"axios": "^1.16.0",
"dompurify": "^3.4.2",
"lottie-web": "^5.13.0",
"lucide-vue-next": "^1.0.0",
"markdown-it": "^14.1.1",
"vue": "^3.5.34"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,73 @@
{
"v": "5.7.4",
"fr": 60,
"ip": 0,
"op": 60,
"w": 200,
"h": 200,
"nm": "dot",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "circle",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100 },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [0], "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] } },
{ "t": 60, "s": [360] }
]
},
"p": { "a": 0, "k": [100, 100, 0] },
"a": { "a": 0, "k": [0, 0, 0] },
"s": {
"a": 1,
"k": [
{ "t": 0, "s": [85, 85, 100], "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] } },
{ "t": 30, "s": [105, 105, 100], "i": { "x": [0.667], "y": [1] }, "o": { "x": [0.333], "y": [0] } },
{ "t": 60, "s": [85, 85, 100] }
]
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "el",
"d": 1,
"s": { "a": 0, "k": [72, 72] },
"p": { "a": 0, "k": [0, 0] }
},
{
"ty": "fl",
"c": { "a": 0, "k": [0.769, 0.361, 0.416, 1] },
"o": { "a": 0, "k": 100 },
"r": 1
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0] },
"a": { "a": 0, "k": [0, 0] },
"s": { "a": 0, "k": [100, 100] },
"r": { "a": 0, "k": 0 },
"o": { "a": 0, "k": 100 }
}
]
}
],
"ip": 0,
"op": 60,
"st": 0,
"bm": 0
}
],
"markers": []
}

7
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import OurStory from './views/OurStory.vue'
</script>
<template>
<OurStory />
</template>

View File

@@ -0,0 +1,77 @@
import axios from 'axios'
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
export const api = axios.create({
baseURL,
headers: { 'Content-Type': 'application/json' },
})
export type MemoryFeatureType = 'heart_window' | 'fissure_page' | 'standard_nepal'
export type NepalArtStyle =
| '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'
export interface Memory {
id: number
story_order: number
date: string
event_title: string
main_text: string
unique_feature_type: MemoryFeatureType
nepal_art_style: NepalArtStyle
/** 首张图 URL与 `images[0]` 一致 */
image: string | null
/** 主图 + 附加图(按后台顺序);仅一张时不轮播 */
images: string[]
is_finale: boolean
}
export interface StatusPayload {
server_time: string
unlocked: boolean
unlock_at: string | null
together_since: string
bgm_enabled: boolean
site_title: string
}
export interface GuestbookMessage {
id: number
nickname: string
message: string
created_at: string
}
export async function fetchMemories(): Promise<Memory[]> {
const { data } = await api.get<Memory[]>('/memories/')
return data
}
export async function fetchStatus(): Promise<StatusPayload> {
const { data } = await api.get<StatusPayload>('/status/')
return data
}
export async function postMessage(payload: {
nickname: string
message: string
}): Promise<GuestbookMessage> {
const { data } = await api.post<GuestbookMessage>('/messages/', payload)
return data
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { usePointerSwipe, usePreferredReducedMotion } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Memory } from '../api/client'
defineProps<{
memory: Memory
}>()
const stageRef = ref<HTMLElement | null>(null)
const split = ref(0)
const reduceMotion = usePreferredReducedMotion()
usePointerSwipe(stageRef, {
disableTextSelect: true,
onSwipeEnd(_e, direction) {
if (reduceMotion.value === 'reduce') return
if (direction === 'left') {
split.value = Math.min(1, split.value + 0.22)
} else if (direction === 'right') {
split.value = Math.max(0, split.value - 0.22)
}
},
})
const rippleShift = computed(() => `${split.value * 14}px`)
function toggleSplit() {
split.value = split.value > 0.35 ? 0 : 0.85
}
</script>
<template>
<div class="mx-auto max-w-lg px-2 py-6 sm:px-4">
<div
ref="stageRef"
class="relative mx-auto min-h-[320px] max-w-md touch-none select-none overflow-hidden rounded-2xl border-[3px] border-slate-900/80 bg-white shadow-[8px_8px_0_rgb(15,23,42,0.55)] ring-2 ring-fuchsia-200/40"
style="touch-action: none"
>
<div class="absolute inset-0 flex">
<div
class="relative flex-1 overflow-hidden bg-lokta-cream/90"
:class="reduceMotion === 'reduce' ? '' : 'transition-transform duration-500 ease-out'"
:style="{ transform: `translateX(-${split * 28}%)` }"
>
<div
class="pointer-events-none absolute -right-1/4 inset-y-0 w-[140%] opacity-90"
style="
background: repeating-conic-gradient(
from 0deg at 60% 45%,
#fbcfe8 0deg 14deg,
#fce7f3 14deg 28deg,
#f9a8d4 28deg 42deg,
#fbcfe8 42deg 56deg
);
mask-image: radial-gradient(circle at 70% 40%, black 0%, transparent 68%);
"
/>
<div class="relative z-[1] flex h-full flex-col justify-center px-3 py-8 sm:px-5">
<p
class="text-right font-brush text-lg leading-relaxed text-lokta-deep sm:text-xl"
>
旁人皆三千江水
</p>
</div>
</div>
<div
class="relative z-10 w-px shrink-0 bg-gradient-to-b from-transparent via-ink/40 to-transparent shadow-[0_0_12px_rgba(61,52,40,0.35)]"
aria-hidden="true"
/>
<div
class="relative flex-1 overflow-hidden bg-lokta-cream/90"
:class="reduceMotion === 'reduce' ? '' : 'transition-transform duration-500 ease-out'"
:style="{ transform: `translateX(${split * 28}%)` }"
>
<div
class="pointer-events-none absolute -left-1/4 inset-y-0 w-[140%] opacity-90"
style="
background: repeating-conic-gradient(
from 180deg at 40% 45%,
#a5f3fc 0deg 16deg,
#cffafe 16deg 32deg,
#22d3ee 32deg 48deg,
#a5f3fc 48deg 64deg
);
mask-image: radial-gradient(circle at 30% 40%, black 0%, transparent 68%);
"
/>
<div class="relative z-[1] flex h-full flex-col justify-center px-3 py-8 sm:px-5">
<p class="font-brush text-lg leading-relaxed text-lokta-deep sm:text-xl">
<span>唯你是心上</span>
<span
class="relative z-20 inline-block -mx-1 font-brush text-2xl text-rose-deep sm:text-3xl"
:style="{ transform: `translateX(${rippleShift})` }"
>涟漪</span>
</p>
</div>
</div>
</div>
</div>
<button
type="button"
class="mx-auto mt-2 block rounded-full border border-paper-shadow bg-white/90 px-4 py-2 text-xs font-medium text-ink shadow-sm transition hover:bg-paper-deep"
@click="toggleSplit"
>
{{ split > 0.35 ? '让裂缝合上' : '轻轻拉开' }}
</button>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<!-- 终章心窗背后柔色漫画夜空 + 零星花瓣线稿 -->
<template>
<div
class="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-2xl bg-gradient-to-b from-violet-100/50 via-pink-50/40 to-lokta-cream/60"
aria-hidden="true"
>
<svg class="absolute inset-0 h-full w-full opacity-70" viewBox="0 0 400 280" xmlns="http://www.w3.org/2000/svg">
<circle cx="300" cy="48" r="22" fill="#fef3c7" stroke="#b45309" stroke-width="1.2" />
<path
d="M80 200 Q120 160 160 200 Q120 220 80 200"
fill="none"
stroke="#db2777"
stroke-width="1.2"
opacity="0.45"
/>
<path
d="M260 220 Q300 180 340 220 Q300 240 260 220"
fill="none"
stroke="#7c3aed"
stroke-width="1.1"
opacity="0.4"
/>
</svg>
</div>
</template>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import { usePointerSwipe, usePreferredReducedMotion } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Memory } from '../api/client'
import HeartMangaBackdrop from './HeartMangaBackdrop.vue'
import { renderMarkdown } from '../utils/renderMarkdown'
const props = defineProps<{
memory: Memory
}>()
const stageRef = ref<HTMLElement | null>(null)
const openAmount = ref(0)
const reduceMotion = usePreferredReducedMotion()
usePointerSwipe(stageRef, {
disableTextSelect: true,
onSwipeEnd(_e, direction) {
if (reduceMotion.value === 'reduce') return
if (direction === 'left') {
openAmount.value = Math.min(1, openAmount.value + 0.28)
} else if (direction === 'right') {
openAmount.value = Math.max(0, openAmount.value - 0.28)
}
},
})
const openDeg = computed(() => openAmount.value * 78)
const bodyHtml = computed(() => renderMarkdown(props.memory.main_text))
function toggleDoors() {
openAmount.value = openAmount.value > 0.4 ? 0 : 0.92
}
</script>
<template>
<div class="mx-auto max-w-lg px-3 py-6 sm:px-4">
<p
class="text-center font-brush text-lg leading-snug text-lokta-deep sm:text-xl"
>
不开<span class="font-hand text-rose-deep"></span>就打开<span
class="text-lokta-teal"
>心墙</span>
</p>
<p
class="mt-2 text-center font-hand text-sm text-ink-muted sm:text-base"
>
我会在你身边 · <span class="font-[family-name:var(--font-hand)] text-rose-deep">open</span>
</p>
<div
ref="stageRef"
class="relative mx-auto mt-8 max-w-md touch-none select-none [perspective:1200px]"
style="touch-action: none"
>
<div
class="relative flex h-[min(52vw,280px)] max-h-[300px] min-h-[220px] items-stretch justify-center overflow-visible rounded-2xl border-[3px] border-white/90 shadow-[6px_6px_0_rgb(30,41,59,0.18)] ring-2 ring-rose-200/50"
>
<HeartMangaBackdrop />
<div
class="pointer-events-none relative z-[3] flex h-full w-1/2 origin-left rounded-l-2xl border border-rose-soft/40 bg-gradient-to-br from-white via-lokta-cream to-lokta-blush/30 shadow-inner will-change-transform"
:class="reduceMotion === 'reduce' ? '' : 'transition-transform duration-300 ease-out'"
:style="{
transform: `rotateY(${-openDeg}deg)`,
transformStyle: 'preserve-3d',
}"
/>
<div
class="pointer-events-none relative z-[3] flex h-full w-1/2 origin-right rounded-r-2xl border border-rose-soft/40 bg-gradient-to-bl from-white via-lokta-cream to-lokta-teal/25 shadow-inner will-change-transform"
:class="reduceMotion === 'reduce' ? '' : 'transition-transform duration-300 ease-out'"
:style="{
transform: `rotateY(${openDeg}deg)`,
transformStyle: 'preserve-3d',
}"
/>
<div
class="pointer-events-none absolute inset-2 z-[2] flex flex-col items-center justify-center rounded-xl bg-paper-deep/90 p-3 text-center text-sm text-ink-muted shadow-inner"
>
<div
class="max-w-none text-left text-xs leading-relaxed text-ink [&_p]:my-1 sm:text-sm"
v-html="bodyHtml"
/>
</div>
</div>
<button
type="button"
class="mx-auto mt-3 block rounded-full border border-paper-shadow bg-white/90 px-4 py-2 text-xs font-medium text-ink shadow-sm transition hover:bg-paper-deep"
@click="toggleDoors"
>
{{ openAmount > 0.4 ? '合上心窗' : '推开一点' }}
</button>
</div>
<div
class="relative mx-auto mt-10 max-w-md rounded-2xl border-4 border-transparent bg-origin-border p-[2px] shadow-[0_16px_40px_-12px_rgba(30,53,80,0.35)]"
style="
background-image: linear-gradient(#f4e8dc, #e8d4c4),
repeating-linear-gradient(
0deg,
#8f3a36,
#8f3a36 10px,
#b84a42 10px,
#b84a42 20px
);
background-origin: border-box;
background-clip: padding-box, border-box;
"
>
<div
class="rounded-xl bg-gradient-to-b from-lokta-cream to-paper-deep/90 p-4 nepal-fiber"
>
<p class="text-center text-xs font-medium tracking-widest text-ink-muted">
藏宝格 · 情书券插槽
</p>
<div
class="relative mx-auto mt-3 h-24 w-[88%] max-w-xs rounded-md border-2 border-dashed border-rose-soft/50 bg-black/5 shadow-inner"
>
<div
class="absolute inset-x-3 bottom-1 top-2 flex items-center justify-center overflow-hidden rounded-sm bg-gradient-to-br from-fuchsia-100 via-pink-200 to-rose-200 shadow-md ring-1 ring-fuchsia-400/40"
>
<svg
class="h-full w-full opacity-95"
viewBox="0 0 320 140"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<pattern
id="h0"
width="18"
height="18"
patternUnits="userSpaceOnUse"
>
<circle cx="3" cy="3" r="1.2" fill="#be185d" opacity="0.35" />
<circle cx="12" cy="11" r="1" fill="#9d174d" opacity="0.3" />
</pattern>
</defs>
<rect width="320" height="140" fill="url(#h0)" />
<text
x="160"
y="42"
text-anchor="middle"
fill="#831843"
font-size="16"
font-family="system-ui, sans-serif"
>
Love 520 · 情书券非流通
</text>
<text
x="160"
y="78"
text-anchor="middle"
fill="#9f1239"
font-size="28"
font-weight="700"
font-family="Georgia, serif"
>
壹佰份喜欢
</text>
<text
x="160"
y="108"
text-anchor="middle"
fill="#881337"
font-size="11"
>
仅供纪念 · 人民银行不存在于本宇宙
</text>
</svg>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import lottie, { type AnimationItem } from 'lottie-web'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
src?: string
loop?: boolean
sizeClass?: string
}>(),
{
src: '/lottie/heart.json',
loop: true,
sizeClass: 'h-20 w-20',
},
)
const host = ref<HTMLDivElement | null>(null)
let anim: AnimationItem | null = null
async function tryLoad() {
if (!host.value || !props.src) return
try {
const res = await fetch(props.src, { method: 'GET' })
if (!res.ok) return
const data = await res.json()
anim?.destroy()
anim = lottie.loadAnimation({
container: host.value,
renderer: 'svg',
loop: props.loop,
autoplay: true,
animationData: data,
})
} catch {
/* optional asset */
}
}
onMounted(() => {
void tryLoad()
})
watch(
() => props.src,
() => {
void tryLoad()
},
)
onBeforeUnmount(() => {
anim?.destroy()
anim = null
})
</script>
<template>
<div
ref="host"
class="[&_svg]:!h-full [&_svg]:!w-full"
:class="sizeClass"
aria-hidden="true"
/>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Heart, Send } from 'lucide-vue-next'
import { ref } from 'vue'
import { postMessage } from '../api/client'
const nickname = ref('')
const message = ref('')
const sending = ref(false)
const done = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
const n = nickname.value.trim()
const m = message.value.trim()
if (!n || !m) {
error.value = '请填写昵称和留言'
return
}
sending.value = true
try {
await postMessage({ nickname: n, message: m })
done.value = true
nickname.value = ''
message.value = ''
} catch {
error.value = '发送失败,请稍后再试'
} finally {
sending.value = false
}
}
</script>
<template>
<section
class="relative mx-auto max-w-lg px-4 pb-28 pt-4 sm:px-6"
aria-labelledby="guestbook-title"
>
<div class="nepal-brick-frame">
<div
class="relative rounded-2xl bg-gradient-to-b from-white/98 to-lokta-cream/40 p-6 ring-1 ring-lokta-deep/5 sm:p-8"
>
<div class="flex items-center gap-2 text-rose-deep">
<Heart class="h-5 w-5" fill="currentColor" aria-hidden="true" />
<h2 id="guestbook-title" class="font-brush text-3xl text-lokta-deep sm:text-4xl">
给她的一句话
</h2>
</div>
<p class="mt-2 text-sm text-ink-muted">
写下手帐最后一页的悄悄话会保存在服务器上
</p>
<form class="mt-6 space-y-4" @submit.prevent="submit">
<div>
<label class="block text-xs font-medium text-ink-muted" for="nick">昵称</label>
<input
id="nick"
v-model="nickname"
maxlength="80"
autocomplete="nickname"
class="mt-1 w-full rounded-xl border border-paper-shadow bg-paper/80 px-4 py-3 text-ink shadow-inner outline-none ring-0 transition focus:border-rose-soft/60 focus:ring-2 focus:ring-rose-soft/25"
placeholder="例如:你的小朋友"
>
</div>
<div>
<label class="block text-xs font-medium text-ink-muted" for="msg">留言</label>
<textarea
id="msg"
v-model="message"
rows="5"
maxlength="2000"
class="mt-1 w-full resize-y rounded-xl border border-paper-shadow bg-paper/80 px-4 py-3 text-ink shadow-inner outline-none transition focus:border-rose-soft/60 focus:ring-2 focus:ring-rose-soft/25"
placeholder="写下你想说的话…"
/>
</div>
<p v-if="error" class="text-sm text-rose-deep">
{{ error }}
</p>
<p v-if="done" class="text-sm text-ink-muted">
已收到你的心意谢谢你打开这一页
</p>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-full bg-rose-soft px-5 py-3 text-sm font-semibold text-white shadow-md transition hover:bg-rose-deep disabled:cursor-not-allowed disabled:opacity-60"
:disabled="sending"
>
<Send class="h-4 w-4" aria-hidden="true" />
{{ sending ? '发送中…' : '寄出留言' }}
</button>
</form>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>
<template>
<div
class="relative overflow-hidden border-y border-lokta-deep/10 bg-gradient-to-r from-lokta-blush/25 via-lokta-cream/80 to-lokta-teal/20 py-7 text-center shadow-[inset_0_10px_30px_rgba(30,53,80,0.06)]"
role="separator"
>
<div
class="pointer-events-none absolute inset-0 nepal-fiber opacity-20 mix-blend-multiply"
aria-hidden="true"
/>
<p
class="relative font-brush text-lg tracking-widest text-lokta-deep sm:text-xl"
>
{{ label }}
</p>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
/** 尼泊尔本式双页晕染 + 中间毛边纸层slot 放标题与装饰 */
</script>
<template>
<section
class="relative isolate min-h-[min(52vh,520px)] overflow-hidden border-b border-paper-shadow/50"
aria-labelledby="nepal-hero-title"
>
<div
class="pointer-events-none absolute inset-y-0 left-0 w-[58%] bg-gradient-to-br from-lokta-blush/90 via-lokta-peach/70 to-transparent opacity-95"
aria-hidden="true"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 w-[58%] bg-gradient-to-bl from-lokta-teal/85 via-cyan-200/50 to-transparent opacity-95"
aria-hidden="true"
/>
<div
class="pointer-events-none absolute -left-20 top-1/4 h-48 w-48 rounded-full bg-lokta-magenta/25 blur-3xl"
aria-hidden="true"
/>
<div
class="pointer-events-none absolute -right-16 bottom-10 h-56 w-56 rounded-full bg-lokta-deep/20 blur-3xl"
aria-hidden="true"
/>
<div
class="pointer-events-none absolute inset-0 nepal-fiber opacity-[0.35] mix-blend-multiply"
aria-hidden="true"
/>
<div
class="relative z-[1] mx-auto flex max-w-lg flex-col items-center px-4 pb-12 pt-10 text-center sm:px-8 sm:pb-14 sm:pt-12"
>
<div
class="nepal-torn-panel relative w-full max-w-md bg-lokta-cream/95 px-5 py-8 shadow-[0_18px_50px_-20px_rgba(30,53,80,0.35)] ring-1 ring-lokta-deep/10 backdrop-blur-[2px] sm:px-8 sm:py-10"
>
<div
class="pointer-events-none absolute inset-0 nepal-fiber opacity-25"
aria-hidden="true"
/>
<div class="relative flex flex-col items-center gap-3 sm:gap-4">
<slot />
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,327 @@
<script setup lang="ts">
import { usePreferredReducedMotion, useSessionStorage } from '@vueuse/core'
import { computed, watch } from 'vue'
import type { Memory, StatusPayload } from '../api/client'
import {
getStoryNavAccentClass,
getStoryShellClasses,
getStoryTransitionName,
} from '../utils/storyPageTransition'
import FissurePage from './FissurePage.vue'
import HeartWindowPage from './HeartWindowPage.vue'
import StandardNepalPage from './StandardNepalPage.vue'
const props = defineProps<{
memories: Memory[]
status: StatusPayload
}>()
const storyIndex = useSessionStorage('nj_story_idx', 0)
const reduceMotion = usePreferredReducedMotion()
watch(
() => props.memories.length,
(n) => {
if (n === 0) return
if (storyIndex.value >= n) storyIndex.value = n - 1
if (storyIndex.value < 0) storyIndex.value = 0
},
{ immediate: true },
)
const current = computed(() => props.memories[storyIndex.value] ?? null)
const pageComponent = computed(() => {
const t = current.value?.unique_feature_type
if (t === 'heart_window') return HeartWindowPage
if (t === 'fissure_page') return FissurePage
return StandardNepalPage
})
const transitionName = computed(() => {
if (reduceMotion.value === 'reduce') return 'story-none'
return getStoryTransitionName(current.value)
})
const shellChrome = computed(() => getStoryShellClasses(current.value))
const navAccent = computed(() => getStoryNavAccentClass(current.value))
function prev() {
storyIndex.value = Math.max(0, storyIndex.value - 1)
}
function next() {
storyIndex.value = Math.min(props.memories.length - 1, storyIndex.value + 1)
}
</script>
<template>
<div v-if="memories.length" class="min-h-dvh">
<div class="relative min-h-dvh">
<div
class="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-paper-shadow/70 bg-paper/88 px-3 py-2 backdrop-blur-md sm:px-4"
>
<button
type="button"
class="rounded-full border border-paper-shadow bg-white/90 px-3 py-1.5 text-xs text-ink shadow-sm transition disabled:opacity-40"
:class="navAccent"
:disabled="storyIndex <= 0"
@click="prev"
>
上一页
</button>
<Transition mode="out-in" name="nj-title">
<p
v-if="current"
:key="current.id"
class="min-w-0 flex-1 truncate text-center text-xs text-ink-muted sm:text-sm"
>
<span class="font-medium text-ink">{{ current.event_title }}</span>
<span class="mx-1 text-ink-muted">·</span>
<span>{{ current.date }}</span>
</p>
</Transition>
<button
type="button"
class="rounded-full border border-paper-shadow bg-white/90 px-3 py-1.5 text-xs text-ink shadow-sm transition disabled:opacity-40"
:class="navAccent"
:disabled="storyIndex >= memories.length - 1"
@click="next"
>
下一页
</button>
</div>
<div
class="relative mx-auto max-w-3xl px-1 pb-24 pt-2 sm:px-4 sm:pb-16 sm:pt-4 [perspective:1400px]"
>
<div
class="relative min-h-[60vh] overflow-hidden rounded-2xl bg-gradient-to-b from-white/70 to-lokta-cream/30 ring-1 transition-[box-shadow,border-color] duration-500 [transform-style:preserve-3d]"
:class="shellChrome"
>
<div class="pointer-events-none absolute inset-0 nepal-fiber opacity-15 mix-blend-multiply" />
<Transition :name="transitionName" mode="out-in">
<div
v-if="current"
:key="current.id"
class="relative z-[1] will-change-transform"
>
<component :is="pageComponent" :memory="current" />
</div>
</Transition>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 顶栏标题轻切换 */
.nj-title-enter-active,
.nj-title-leave-active {
transition: opacity 0.28s ease, transform 0.28s ease;
}
.nj-title-enter-from {
opacity: 0;
transform: translateY(6px);
}
.nj-title-leave-to {
opacity: 0;
transform: translateY(-5px);
}
/* 无障碍:减弱动效 */
.story-none-enter-active,
.story-none-leave-active {
transition: opacity 0.2s ease;
}
.story-none-enter-from,
.story-none-leave-to {
opacity: 0;
}
/* 通用时长曲线 */
.story-heart-enter-active,
.story-heart-leave-active,
.story-fissure-enter-active,
.story-fissure-leave-active,
.story-dawn-enter-active,
.story-dawn-leave-active,
.story-blush-enter-active,
.story-blush-leave-active,
.story-mint-enter-active,
.story-mint-leave-active,
.story-saffron-enter-active,
.story-saffron-leave-active,
.story-indigo-enter-active,
.story-indigo-leave-active,
.story-lotus-enter-active,
.story-lotus-leave-active,
.story-rose-enter-active,
.story-rose-leave-active,
.story-blue-enter-active,
.story-blue-leave-active,
.story-night-enter-active,
.story-night-leave-active,
.story-gold-enter-active,
.story-gold-leave-active,
.story-paper-enter-active,
.story-paper-leave-active {
transition:
opacity 0.52s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.52s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.52s cubic-bezier(0.22, 1, 0.36, 1),
clip-path 0.52s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 终章 · 心窗:推门而入 */
.story-heart-enter-from {
opacity: 0;
transform: perspective(1000px) rotateY(-16deg) scale(0.94);
filter: blur(2px);
}
.story-heart-leave-to {
opacity: 0;
transform: perspective(900px) rotateY(10deg) scale(1.03);
filter: blur(1px);
}
/* 争执 · 裂缝:向中间收拢再裂开 */
.story-fissure-enter-from {
opacity: 0;
clip-path: inset(0 46% 0 46%);
transform: scale(0.94);
filter: saturate(1.25) hue-rotate(-8deg);
}
.story-fissure-leave-to {
opacity: 0;
clip-path: inset(42% 0 42% 0);
transform: scale(0.97);
filter: saturate(0.85);
}
/* 初见 · 晨光 */
.story-dawn-enter-from {
opacity: 0;
transform: translateY(28px) scale(0.97);
filter: brightness(1.2) saturate(1.08);
}
.story-dawn-leave-to {
opacity: 0;
transform: translateY(-18px) scale(1.02);
filter: brightness(0.92);
}
/* 别扭 · 粉雾 */
.story-blush-enter-from {
opacity: 0;
transform: translateX(-18px) scale(0.98);
filter: blur(1px) saturate(1.15);
}
.story-blush-leave-to {
opacity: 0;
transform: translateX(14px) scale(1.01);
filter: saturate(0.9);
}
/* 和好 · 薄荷 */
.story-mint-enter-from {
opacity: 0;
transform: scale(0.94);
filter: blur(2px) brightness(1.05);
}
.story-mint-leave-to {
opacity: 0;
transform: scale(1.04);
filter: blur(1px);
}
/* 小家 · 姜黄暖调 */
.story-saffron-enter-from {
opacity: 0;
transform: translate(12px, 16px) scale(0.96) rotate(-0.8deg);
filter: brightness(1.08);
}
.story-saffron-leave-to {
opacity: 0;
transform: translate(-10px, -12px) scale(1.02) rotate(0.6deg);
}
/* 宜家 · 靛蓝利落 */
.story-indigo-enter-from {
opacity: 0;
transform: translateX(22px) skewX(-2deg) scale(0.97);
filter: contrast(1.05);
}
.story-indigo-leave-to {
opacity: 0;
transform: translateX(-18px) skewX(1.5deg);
}
/* 莲花瓣 */
.story-lotus-enter-from {
opacity: 0;
transform: scale(0.92) rotate(-1.2deg);
filter: saturate(1.2) brightness(1.06);
}
.story-lotus-leave-to {
opacity: 0;
transform: scale(1.05) rotate(1deg);
}
/* 曼陀罗 / 玫瑰螺旋 */
.story-rose-enter-from {
opacity: 0;
transform: rotate(-3deg) scale(0.95);
filter: saturate(1.18);
}
.story-rose-leave-to {
opacity: 0;
transform: rotate(2deg) scale(1.02);
}
/* 青蓝系 */
.story-blue-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.97);
filter: hue-rotate(12deg) saturate(1.1);
}
.story-blue-leave-to {
opacity: 0;
transform: translateY(-12px);
filter: hue-rotate(-6deg);
}
/* 夜空 */
.story-night-enter-from {
opacity: 0;
transform: scale(1.06);
filter: brightness(0.75) blur(2px);
}
.story-night-leave-to {
opacity: 0;
transform: scale(0.94);
filter: brightness(1.15);
}
/* 金缮手工纸 */
.story-gold-enter-from {
opacity: 0;
transform: translateY(12px) rotate(1deg) scale(0.97);
filter: sepia(0.15) brightness(1.05);
}
.story-gold-leave-to {
opacity: 0;
transform: translateY(-8px) rotate(-0.8deg);
}
/* 默认纸页 */
.story-paper-enter-from {
opacity: 0;
transform: rotateY(-10deg) translateZ(-14px);
}
.story-paper-leave-to {
opacity: 0;
transform: rotateY(8deg) translateZ(-8px);
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
/** 后端已规范为 https://music.163.com/... 的播放器地址 */
src: string
}>()
const safeSrc = computed(() => {
let u = (props.src || '').trim()
if (!u) return ''
if (u.startsWith('//')) u = `https:${u}`
try {
const url = new URL(u)
if (url.protocol !== 'https:') return ''
const h = url.hostname.toLowerCase()
if (h !== 'music.163.com') return ''
return url.href.split('#')[0]
} catch {
return ''
}
})
</script>
<template>
<div
v-if="safeSrc"
class="overflow-hidden rounded-xl border border-lokta-deep/15 bg-white/80 shadow-sm"
>
<iframe
:src="safeSrc"
title="网易云音乐"
frameborder="no"
border="0"
marginwidth="0"
marginheight="0"
width="330"
height="86"
class="mx-auto block border-0"
allow="autoplay; encrypted-media; fullscreen"
referrerpolicy="strict-origin-when-cross-origin"
/>
</div>
</template>

View File

@@ -0,0 +1,209 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Memory } from '../api/client'
import WashiTape from './WashiTape.vue'
const props = defineProps<{
memory: Memory
index: number
}>()
const root = ref<HTMLElement | null>(null)
const visible = ref(false)
const loadedSet = ref<Record<number, boolean>>({})
const activeIndex = ref(0)
const reduceMotion = usePreferredReducedMotion()
let observer: IntersectionObserver | null = null
let slideTimer: ReturnType<typeof setInterval> | undefined
const rotateClass = computed(() =>
props.index % 2 === 0 ? '-rotate-1 sm:rotate-1' : 'rotate-1 sm:-rotate-1',
)
/** 主图 + 附加图;兼容旧数据仅有 image */
const imageUrls = computed(() => {
const m = props.memory
if (m.images?.length) return m.images
if (m.image) return [m.image]
return []
})
const hasMultiple = computed(() => imageUrls.value.length > 1)
const excerpt = computed(() => {
const line =
props.memory.main_text?.split('\n').find((l) => l.trim())?.trim() ?? ''
const plain = line.replace(/\*\*/g, '').replace(/[#>`]/g, '')
if (!plain) return ''
return plain.length > 140 ? `${plain.slice(0, 140)}` : plain
})
function onImgLoad(i: number) {
loadedSet.value = { ...loadedSet.value, [i]: true }
}
function stackStyle(i: number) {
const urls = imageUrls.value
const n = urls.length
if (n <= 1) {
return {
zIndex: 2,
transform: 'translate(0,0) scale(1) rotate(0deg)',
opacity: 1,
}
}
const flat = reduceMotion.value === 'reduce'
const rel = (i - activeIndex.value + n) % n
const z = 20 - rel
if (flat) {
return {
zIndex: z,
transform: 'translate(0,0) scale(1)',
opacity: rel === 0 ? 1 : 0,
}
}
const off = rel * 6
const scale = 1 - rel * 0.028
const rot = rel * 0.9 - (n - 1) * 0.35
const opacity = rel === 0 ? 1 : Math.max(0.32, 0.78 - rel * 0.12)
return {
zIndex: z,
transform: `translate(${off}px, ${off * 0.65}px) scale(${scale}) rotate(${rot}deg)`,
opacity,
}
}
function startCarousel() {
if (slideTimer) clearInterval(slideTimer)
slideTimer = undefined
if (reduceMotion.value === 'reduce') return
if (!hasMultiple.value || !visible.value) return
slideTimer = setInterval(() => {
const n = imageUrls.value.length
if (n <= 1) return
activeIndex.value = (activeIndex.value + 1) % n
}, 3000)
}
watch(
() => props.memory.id,
() => {
activeIndex.value = 0
loadedSet.value = {}
startCarousel()
},
)
watch([hasMultiple, visible], () => {
startCarousel()
})
watch(imageUrls, () => {
activeIndex.value = 0
startCarousel()
})
watch(reduceMotion, () => {
activeIndex.value = 0
startCarousel()
})
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
visible.value = true
observer?.disconnect()
observer = null
startCarousel()
}
}
},
{ root: null, threshold: 0.12 },
)
if (root.value) observer.observe(root.value)
})
onBeforeUnmount(() => {
observer?.disconnect()
if (slideTimer) clearInterval(slideTimer)
})
</script>
<template>
<article
ref="root"
class="relative mx-auto max-w-[min(100%,320px)] transition-all duration-700 ease-out"
:class="
visible
? `translate-y-0 opacity-100 ${rotateClass}`
: 'translate-y-6 opacity-0 rotate-0'
"
>
<WashiTape
class="-left-2 top-3"
:tone="index % 3 === 0 ? 'pink' : index % 3 === 1 ? 'mint' : 'cream'"
rotate="-rotate-12"
width-class="w-20"
/>
<div
class="nepal-deckle-frame relative bg-white p-3 pb-16 shadow-[0_14px_44px_-10px_rgba(30,53,80,0.28)] ring-1 ring-lokta-deep/10"
>
<div
class="pointer-events-none absolute inset-2 nepal-fiber opacity-[0.18] mix-blend-multiply"
aria-hidden="true"
/>
<div class="relative aspect-[4/5] overflow-visible rounded-sm bg-paper-deep">
<template v-if="imageUrls.length">
<div
v-if="!loadedSet[activeIndex] && imageUrls[activeIndex]"
class="absolute inset-0 z-[5] animate-pulse bg-gradient-to-br from-paper-deep to-paper-shadow"
/>
<div
v-for="(url, i) in imageUrls"
:key="`${url}-${i}`"
class="absolute inset-0 overflow-hidden rounded-sm shadow-[2px_3px_0_rgba(30,53,80,0.12)] ring-1 ring-black/5 transition-[transform,opacity] duration-500 ease-out"
:style="stackStyle(i)"
>
<img
:src="url"
:alt="`${memory.event_title} · ${i + 1}/${imageUrls.length}`"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-opacity duration-500"
:class="loadedSet[i] ? 'opacity-100' : 'opacity-0'"
@load="onImgLoad(i)"
/>
</div>
<div
v-if="hasMultiple"
class="pointer-events-none absolute bottom-2 right-2 z-[25] rounded-full bg-black/45 px-2 py-0.5 font-mono text-[10px] text-white tabular-nums"
aria-hidden="true"
>
{{ activeIndex + 1 }} / {{ imageUrls.length }}
</div>
</template>
<div
v-else
class="flex h-full min-h-[200px] items-center justify-center text-ink-muted text-sm"
>
待上传照片
</div>
</div>
<p
class="font-hand text-2xl leading-tight text-ink mt-3 px-1 text-center sm:text-3xl"
>
{{ memory.event_title }}
</p>
<p
v-if="excerpt"
class="mt-3 px-1 text-center text-sm leading-relaxed text-ink-muted"
>
{{ excerpt }}
</p>
</div>
</article>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Memory } from '../api/client'
import { getStandardNepalTheme } from '../constants/nepalThemes'
import MangaSceneRouter from './standardScenes/MangaSceneRouter.vue'
import PolaroidCard from './PolaroidCard.vue'
import { renderMarkdown } from '../utils/renderMarkdown'
const props = defineProps<{
memory: Memory
}>()
const bodyHtml = computed(() => renderMarkdown(props.memory.main_text))
const theme = computed(() => getStandardNepalTheme(props.memory.nepal_art_style))
/** 插画层较满时压低网点,避免糊成一团 */
const halftoneClass = computed(() => {
const busy = new Set([
'indigo_ikea',
'dawn_meet',
'saffron_home',
'blush_argue',
'lotus_petal',
'mandala_pink',
'spiral_rose',
])
return busy.has(props.memory.nepal_art_style)
? 'opacity-[0.14]'
: 'opacity-[0.32]'
})
const tornPanelClass = computed(() => {
const s = props.memory.nepal_art_style
if (s === 'indigo_ikea') {
return 'border-indigo-200/80 bg-white/95 shadow-[4px_5px_0_rgb(30,58,138,0.12)] ring-2 ring-indigo-100/40'
}
if (s === 'dawn_meet') {
return 'border-amber-200/70 bg-white/92 shadow-[4px_5px_0_rgb(180,83,9,0.1)] ring-2 ring-amber-100/35'
}
if (s === 'saffron_home') {
return 'border-amber-300/60 bg-white/93 shadow-[4px_5px_0_rgb(180,83,9,0.12)] ring-2 ring-orange-100/40'
}
if (s === 'mint_reconcile') {
return 'border-emerald-200/70 bg-white/94 shadow-[3px_4px_0_rgb(5,150,105,0.1)] ring-2 ring-emerald-50/50'
}
return 'border-paper-shadow/60 ring-1 ring-lokta-deep/10'
})
</script>
<template>
<div
class="relative mx-auto max-w-lg overflow-hidden rounded-3xl bg-gradient-to-br px-3 py-8 sm:px-4"
:class="theme.gradient"
>
<MangaSceneRouter
class="absolute inset-0 z-0"
:style-key="memory.nepal_art_style"
/>
<div
class="pointer-events-none absolute inset-2 z-[1] rounded-3xl mix-blend-multiply"
:class="[theme.overlayClass, halftoneClass]"
style="
background-image: radial-gradient(circle at 20% 20%, rgb(196 92 106 / 0.2) 0 2px, transparent 3px),
radial-gradient(circle at 80% 30%, rgb(78 196 212 / 0.18) 0 2px, transparent 3px),
radial-gradient(circle at 50% 80%, rgb(30 53 80 / 0.12) 0 2px, transparent 3px);
background-size: 28px 28px, 34px 34px, 22px 22px;
"
aria-hidden="true"
/>
<div class="relative z-[2]">
<PolaroidCard :memory="memory" :index="memory.story_order" />
<div
class="nepal-torn-panel mx-auto mt-8 max-w-md border bg-white/90 px-5 py-6 text-left shadow-lg backdrop-blur-[1px]"
:class="tornPanelClass"
>
<h3 class="font-brush text-2xl text-lokta-deep">
{{ memory.event_title }}
</h3>
<div
class="mt-3 text-sm leading-relaxed text-ink [&_a]:text-rose-deep [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-rose-soft/50 [&_blockquote]:pl-3 [&_blockquote]:text-ink-muted [&_code]:rounded [&_code]:bg-paper-deep [&_code]:px-1 [&_strong]:text-ink"
v-html="bodyHtml"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { Memory } from '../api/client'
import PolaroidCard from './PolaroidCard.vue'
defineProps<{
memories: Memory[]
}>()
function formatDate(d: string) {
const [y, m, day] = d.split('-')
return `${y} · ${m} · ${day}`
}
</script>
<template>
<div class="relative mx-auto max-w-lg px-4 pb-24 pt-4 sm:px-6">
<div
class="pointer-events-none absolute inset-y-0 left-0 w-1/2 bg-gradient-to-br from-lokta-blush/20 via-transparent to-transparent"
aria-hidden="true"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 w-1/2 bg-gradient-to-bl from-lokta-teal/15 via-transparent to-transparent"
aria-hidden="true"
/>
<div
class="pointer-events-none absolute inset-0 nepal-fiber opacity-[0.12] mix-blend-multiply"
aria-hidden="true"
/>
<svg
class="pointer-events-none absolute bottom-0 left-1/2 top-0 z-0 w-5 -translate-x-1/2 overflow-visible text-rose-soft/45"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M10 0 Q14 120 8 240 T10 480 T8 720 T10 960 T9 1200"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-dasharray="5 10"
vector-effect="non-scaling-stroke"
/>
</svg>
<ol class="relative z-[1] space-y-16 sm:space-y-20">
<li
v-for="(m, i) in memories"
:key="m.id"
class="relative flex flex-col gap-3"
>
<div
class="flex items-center justify-center gap-2 text-sm text-rose-deep"
>
<span
class="inline-flex h-3 w-3 shrink-0 rounded-full bg-rose-soft shadow ring-2 ring-white"
/>
<time
class="font-hand text-xl tracking-wide"
:datetime="m.date"
>{{ formatDate(m.date) }}</time>
</div>
<div
class="flex w-full"
:class="i % 2 === 0 ? 'justify-start pr-8 sm:pr-14' : 'justify-end pl-8 sm:pl-14'"
>
<PolaroidCard :memory="m" :index="i" />
</div>
</li>
</ol>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
calendarDiffYmd,
totalHoursBetween,
ymdInShanghai,
} from '../utils/togetherDuration'
export type TogetherMilestone = {
title: string
/** `YYYY-MM-DD` */
date: string
}
const props = withDefaults(
defineProps<{
togetherSinceIso: string | null | undefined
serverOffsetMs: number
milestones?: TogetherMilestone[]
}>(),
{ milestones: () => [] },
)
const tick = ref(0)
let tickTimer: ReturnType<typeof setInterval> | undefined
const slideIndex = ref(0)
let slideTimer: ReturnType<typeof setInterval> | undefined
function serverNowMs(): number {
return Date.now() + props.serverOffsetMs
}
const parts = computed(() => {
tick.value
const raw = props.togetherSinceIso
if (!raw) return null
const startMs = Date.parse(raw)
if (!Number.isFinite(startMs)) return null
const endMs = serverNowMs()
if (endMs < startMs) {
return { years: 0, months: 0, days: 0, hoursTotal: 0 }
}
const startYmd = ymdInShanghai(startMs)
const endYmd = ymdInShanghai(endMs)
const { years, months, days } = calendarDiffYmd(startYmd, endYmd)
const hoursTotal = totalHoursBetween(startMs, endMs, 1)
return { years, months, days, hoursTotal }
})
const slideCount = computed(() => {
let n = 0
if (parts.value) n += 1
n += props.milestones.length
return n
})
function formatCnDate(dateStr: string): string {
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(dateStr.trim())
if (!m) return dateStr
return `${Number(m[1])}${Number(m[2])}${Number(m[3])}`
}
function eventStartMs(dateStr: string): number {
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(dateStr.trim())
if (!m) return NaN
const y = m[1].padStart(4, '0')
const mo = m[2].padStart(2, '0')
const d = m[3].padStart(2, '0')
return Date.parse(`${y}-${mo}-${d}T00:00:00+08:00`)
}
function milestoneMeta(m: TogetherMilestone) {
const start = eventStartMs(m.date)
if (!Number.isFinite(start)) {
return { label: formatCnDate(m.date), rel: '' as string }
}
const now = serverNowMs()
const dayMs = 86_400_000
const diffDays = Math.floor((now - start) / dayMs)
const rel =
diffDays >= 0
? `自那天起已满 ${diffDays} 个自然日`
: `还有 ${-diffDays} 个自然日`
return { label: formatCnDate(m.date), rel }
}
type Slide = { kind: 'together' } | { kind: 'milestone'; index: number }
const currentSlide = computed((): Slide | null => {
const n = slideCount.value
if (n === 0) return null
const i = slideIndex.value % n
if (parts.value) {
if (i === 0) return { kind: 'together' }
return { kind: 'milestone', index: i - 1 }
}
return { kind: 'milestone', index: i }
})
const milestoneView = computed(() => {
tick.value
const s = currentSlide.value
if (!s || s.kind !== 'milestone') return null
const m = props.milestones[s.index]
if (!m) return null
const meta = milestoneMeta(m)
return { title: m.title, label: meta.label, rel: meta.rel }
})
watch(slideCount, (n) => {
if (n <= 0) {
slideIndex.value = 0
return
}
slideIndex.value %= n
})
onMounted(() => {
tickTimer = setInterval(() => {
tick.value += 1
}, 30_000)
slideTimer = setInterval(() => {
const n = slideCount.value
if (n <= 0) return
slideIndex.value = (slideIndex.value + 1) % n
}, 5_000)
})
onBeforeUnmount(() => {
if (tickTimer) clearInterval(tickTimer)
if (slideTimer) clearInterval(slideTimer)
})
</script>
<template>
<div
v-if="currentSlide"
class="min-h-[4.25rem] text-sm tabular-nums text-ink-muted transition-opacity duration-300"
>
<template v-if="currentSlide.kind === 'together' && parts">
<div class="space-y-1">
<p>
我们在一起
<span class="font-medium text-lokta-deep">{{ parts.years }}</span>
<span class="font-medium text-lokta-deep">{{ parts.months }}</span>
<span class="font-medium text-lokta-deep">{{ parts.days }}</span>
</p>
<p>
相当于约
<span class="font-medium text-lokta-deep">{{ parts.hoursTotal }}</span>
小时
</p>
</div>
</template>
<template v-else-if="milestoneView">
<div class="space-y-1">
<p class="font-medium text-lokta-deep">
{{ milestoneView.title }}
</p>
<p>{{ milestoneView.label }}</p>
<p v-if="milestoneView.rel" class="text-xs">
{{ milestoneView.rel }}
</p>
</div>
</template>
</div>
<p v-else class="min-h-[4.25rem] text-xs text-ink-muted">
请在后台配置在一起的起始时间或载入手帐章节以展示纪念日期
</p>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
withDefaults(
defineProps<{
tone?: 'pink' | 'mint' | 'cream'
rotate?: string
widthClass?: string
}>(),
{
tone: 'pink',
rotate: '-rotate-3',
widthClass: 'w-24',
},
)
const toneClass = {
pink: 'bg-tape-pink/75',
mint: 'bg-tape-mint/75',
cream: 'bg-tape-cream/80',
}
</script>
<template>
<div
class="pointer-events-none absolute z-10 h-7 rounded-sm shadow-sm mix-blend-multiply ring-1 ring-ink/5"
:class="[toneClass[tone], rotate, widthClass]"
aria-hidden="true"
/>
</template>

View File

@@ -0,0 +1,20 @@
<!-- 争执粉蓝对切 + 中间张力线漫画式 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-br from-rose-200/50 via-white/30 to-cyan-200/45" />
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M200 0 L420 0 L360 320 L200 320 Z" fill="#fbcfe8" stroke="#9d174d" stroke-width="1.4" opacity="0.55" />
<path d="M-20 0 L200 0 L200 320 L-40 320 Z" fill="#a5f3fc" stroke="#0e7490" stroke-width="1.4" opacity="0.5" />
<path
d="M198 20 L202 300"
stroke="#1e293b"
stroke-width="2.5"
stroke-dasharray="8 6"
stroke-linecap="round"
opacity="0.45"
/>
<path d="M180 80 L220 100 L200 120 Z" fill="#fda4af" stroke="#881337" stroke-width="1.2" opacity="0.7" />
<path d="M200 200 L240 220 L210 240 Z" fill="#67e8f9" stroke="#155e75" stroke-width="1.2" opacity="0.65" />
</svg>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<!-- 青蓝系海浪层 + 透气留白偏少年漫清爽 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-b from-sky-100/70 via-cyan-50/40 to-white/30" />
<svg class="absolute bottom-0 left-0 h-1/2 w-full" viewBox="0 0 400 120" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0 40 Q100 10 200 40 T400 35 L400 120 L0 120 Z"
fill="#bae6fd"
stroke="#0369a1"
stroke-width="1.2"
opacity="0.65"
/>
<path
d="M0 70 Q120 45 240 70 T400 65 L400 120 L0 120 Z"
fill="#7dd3fc"
stroke="#0c4a6e"
stroke-width="1.1"
opacity="0.5"
/>
<circle cx="320" cy="32" r="6" fill="#e0f2fe" stroke="#0369a1" stroke-width="1" />
<circle cx="300" cy="28" r="4" fill="#e0f2fe" stroke="#0369a1" stroke-width="1" />
</svg>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<!-- 初见晨光日漫清晨窗景 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div
class="absolute -right-16 -top-24 h-56 w-56 rounded-full bg-gradient-to-br from-amber-200/80 via-orange-100/60 to-rose-100/40 blur-2xl"
/>
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="dawn-ray" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fdba74" stop-opacity="0.55" />
<stop offset="100%" stop-color="#fecdd3" stop-opacity="0" />
</linearGradient>
</defs>
<path d="M-20 0 L120 280 L80 300 L-40 40 Z" fill="url(#dawn-ray)" />
<path d="M40 -10 L200 300 L150 310 L10 20 Z" fill="url(#dawn-ray)" opacity="0.65" />
<ellipse cx="320" cy="70" rx="52" ry="28" fill="#fff7ed" stroke="#ea580c" stroke-width="1.2" opacity="0.9" />
<ellipse cx="300" cy="68" rx="52" ry="28" fill="#fff7ed" stroke="#ea580c" stroke-width="1.2" opacity="0.75" />
<path d="M60 200 Q200 160 340 200 L340 320 L60 320 Z" fill="#ffedd5" stroke="#c2410c" stroke-width="1.1" opacity="0.35" />
</svg>
<div
class="absolute bottom-8 left-6 h-24 w-32 rounded-t-lg border-2 border-amber-800/50 bg-amber-50/80 shadow-[3px_4px_0_rgba(154,52,18,0.25)]"
style="clip-path: polygon(0 100%, 0 12%, 12% 0, 88% 0, 100% 12%, 100% 100%)"
/>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<!-- 金缮/手工纸暖金几何与撕边暗示 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-br from-amber-100/80 via-yellow-50/60 to-stone-100/50" />
<svg class="absolute inset-0 h-full w-full opacity-80" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="70" cy="100" r="40" fill="#fef3c7" stroke="#b45309" stroke-width="1.3" />
<rect
x="240"
y="60"
width="100"
height="72"
rx="2"
transform="rotate(-4 290 96)"
fill="#fffbeb"
stroke="#92400e"
stroke-width="1.3"
/>
<path
d="M20 260 L60 240 L100 268 L140 248 L180 272 L220 252 L260 278 L300 258 L340 270 L380 250"
stroke="#ca8a04"
stroke-width="1.5"
fill="none"
opacity="0.5"
/>
</svg>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<!-- 租房钥匙窗框小家的块面插画 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-b from-amber-50/90 via-orange-50/50 to-amber-100/40" />
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="48" y="72" width="120" height="100" rx="2" fill="#fffbeb" stroke="#b45309" stroke-width="1.4" />
<path d="M48 120h120M108 72v100" stroke="#b45309" stroke-width="1.2" opacity="0.6" />
<path
d="M260 200 L280 180 L300 200 L300 248 L260 248 Z"
fill="#fde68a"
stroke="#92400e"
stroke-width="1.4"
/>
<circle cx="280" cy="212" r="10" fill="none" stroke="#92400e" stroke-width="1.6" />
<path d="M280 212v14" stroke="#92400e" stroke-width="1.6" stroke-linecap="round" />
<rect x="220" y="200" width="64" height="8" rx="2" fill="#d97706" stroke="#78350f" stroke-width="1.1" />
<ellipse cx="200" cy="288" rx="160" ry="18" fill="#fed7aa" stroke="#c2410c" stroke-width="1" opacity="0.45" />
</svg>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<!-- 宜家扁平插画 + 错位阴影营造立体室内 3D -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div
class="absolute -bottom-6 left-1/2 h-[58%] w-[118%] -translate-x-1/2 bg-gradient-to-t from-indigo-100/90 via-slate-100/70 to-transparent"
style="clip-path: polygon(8% 100%, 92% 100%, 100% 0%, 0% 0%)"
/>
<svg
class="absolute bottom-0 left-1/2 h-[72%] w-[min(120%,520px)] -translate-x-1/2"
viewBox="0 0 420 280"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id="ikea-floor" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#e0e7ef" />
</linearGradient>
</defs>
<!-- 地板略俯视的梯形 -->
<path
d="M20 195 L400 210 L400 275 L10 275 Z"
fill="url(#ikea-floor)"
stroke="#475569"
stroke-width="1.4"
style="filter: drop-shadow(0 3px 0 rgb(30 58 90 / 0.1))"
/>
<!-- 后墙 -->
<path
d="M48 38 L372 48 L372 188 L48 178 Z"
fill="#f8fafc"
stroke="#64748b"
stroke-width="1.3"
style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.08))"
/>
<!-- 置物架 -->
<g style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.12))">
<rect x="248" y="68" width="98" height="7" rx="1" fill="#fef9c3" stroke="#334155" stroke-width="1.2" />
<rect x="248" y="92" width="98" height="7" rx="1" fill="#fef9c3" stroke="#334155" stroke-width="1.2" />
<rect x="248" y="116" width="98" height="7" rx="1" fill="#fef9c3" stroke="#334155" stroke-width="1.2" />
<rect x="244" y="62" width="6" height="68" fill="#e2e8f0" stroke="#334155" stroke-width="1" />
<rect x="344" y="62" width="6" height="68" fill="#e2e8f0" stroke="#334155" stroke-width="1" />
</g>
<!-- 落地灯 -->
<g style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.12))">
<ellipse cx="118" cy="198" rx="36" ry="10" fill="#cbd5e1" stroke="#334155" stroke-width="1.1" />
<path d="M118 198 L118 118" stroke="#334155" stroke-width="2.2" stroke-linecap="round" />
<path
d="M86 118 Q118 88 150 118 Q118 102 86 118"
fill="#fef3c7"
stroke="#334155"
stroke-width="1.2"
/>
</g>
<!-- 单人沙发块面 -->
<g style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.12))">
<rect x="72" y="148" width="112" height="44" rx="6" fill="#93c5fd" stroke="#1e3a5a" stroke-width="1.4" />
<rect x="84" y="136" width="88" height="22" rx="5" fill="#bfdbfe" stroke="#1e3a5a" stroke-width="1.3" />
<rect x="168" y="158" width="18" height="34" rx="2" fill="#60a5fa" stroke="#1e3a5a" stroke-width="1.1" />
</g>
<!-- 小边几 + 马克杯 -->
<g style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.12))">
<rect x="286" y="168" width="52" height="40" rx="3" fill="#e2e8f0" stroke="#334155" stroke-width="1.2" />
<rect x="300" y="152" width="24" height="20" rx="2" fill="#fff" stroke="#334155" stroke-width="1.1" />
</g>
<!-- 地毯条纹 -->
<g opacity="0.85" style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.1))">
<ellipse cx="210" cy="228" rx="130" ry="22" fill="#fef08a" stroke="#854d0e" stroke-width="1.1" />
<path d="M120 228h40M170 228h40M220 228h40M270 228h40" stroke="#a16207" stroke-width="1.4" />
</g>
<!-- 小盆栽 -->
<g style="filter: drop-shadow(0 2px 0 rgb(30 58 90 / 0.1))">
<rect x="318" y="132" width="28" height="24" rx="2" fill="#fecdd3" stroke="#334155" stroke-width="1.1" />
<path d="M332 132 Q322 108 332 96 Q342 108 332 132" fill="#86efac" stroke="#166534" stroke-width="1.1" />
</g>
</svg>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<!-- 莲花瓣层叠弧线 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-b from-violet-100/55 via-pink-50/45 to-fuchsia-50/35" />
<svg class="absolute inset-0 h-full w-full opacity-90" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M200 260 C120 200 120 120 200 80 C280 120 280 200 200 260Z"
fill="#fce7f3"
stroke="#86198f"
stroke-width="1.2"
opacity="0.55"
/>
<path
d="M200 240 C150 190 150 130 200 100 C250 130 250 190 200 240Z"
fill="#fdf4ff"
stroke="#7e22ce"
stroke-width="1.15"
opacity="0.65"
/>
<path
d="M200 220 C170 185 170 145 200 120 C230 145 230 185 200 220Z"
fill="#fff"
stroke="#a21caf"
stroke-width="1.1"
opacity="0.75"
/>
</svg>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import type { NepalArtStyle } from '../../api/client'
import ArgueAnimeScene from './ArgueAnimeScene.vue'
import BlueAnimeScene from './BlueAnimeScene.vue'
import DawnAnimeScene from './DawnAnimeScene.vue'
import GoldAnimeScene from './GoldAnimeScene.vue'
import HomeAnimeScene from './HomeAnimeScene.vue'
import IkeaAnimeScene from './IkeaAnimeScene.vue'
import LotusAnimeScene from './LotusAnimeScene.vue'
import MintAnimeScene from './MintAnimeScene.vue'
import NightAnimeScene from './NightAnimeScene.vue'
import PaperAnimeScene from './PaperAnimeScene.vue'
import RoseAnimeScene from './RoseAnimeScene.vue'
const props = defineProps<{
styleKey: NepalArtStyle
}>()
const SCENES: Record<string, Component> = {
indigo_ikea: IkeaAnimeScene,
dawn_meet: DawnAnimeScene,
blush_argue: ArgueAnimeScene,
mint_reconcile: MintAnimeScene,
saffron_home: HomeAnimeScene,
mandala_blue: BlueAnimeScene,
spiral_cyan: BlueAnimeScene,
ocean_prayer: BlueAnimeScene,
night_star: NightAnimeScene,
lokta_gold: GoldAnimeScene,
terra_torn: GoldAnimeScene,
mandala_pink: RoseAnimeScene,
spiral_rose: RoseAnimeScene,
lotus_petal: LotusAnimeScene,
desert_rose: RoseAnimeScene,
}
const Scene = computed(() => SCENES[props.styleKey] ?? PaperAnimeScene)
</script>
<template>
<component :is="Scene" />
</template>

View File

@@ -0,0 +1,24 @@
<!-- 和好薄荷泡泡与柔光 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div
class="absolute -left-10 top-1/4 h-40 w-40 rounded-full bg-emerald-200/50 blur-2xl"
/>
<div
class="absolute bottom-1/4 -right-8 h-36 w-36 rounded-full bg-teal-200/45 blur-2xl"
/>
<svg class="absolute inset-0 h-full w-full opacity-90" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="70" cy="80" r="22" fill="#d1fae5" stroke="#047857" stroke-width="1.3" />
<circle cx="320" cy="120" r="18" fill="#ccfbf1" stroke="#0f766e" stroke-width="1.2" />
<circle cx="260" cy="240" r="28" fill="#ecfdf5" stroke="#059669" stroke-width="1.2" />
<path
d="M120 200 Q200 140 280 200"
stroke="#34d399"
stroke-width="2"
stroke-linecap="round"
opacity="0.5"
/>
<path d="M150 60 Q170 40 190 60" stroke="#6ee7b7" stroke-width="1.8" stroke-linecap="round" />
</svg>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<!-- 夜空星屑 + 月芽线稿 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-b from-slate-200/50 via-indigo-100/35 to-violet-50/40" />
<svg class="absolute inset-0 h-full w-full" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M300 48 A28 28 0 1 1 300 90"
stroke="#312e81"
stroke-width="1.8"
fill="#eef2ff"
opacity="0.85"
/>
<g stroke="#4338ca" stroke-width="1.2" opacity="0.55">
<path d="M60 70l2 6M62 76l-6-2" />
<path d="M120 40l2 5M122 45l-5-2" />
<path d="M200 55l2 5M202 60l-5-2" />
<path d="M340 90l2 5M342 95l-5-2" />
<path d="M90 140l2 5M92 145l-5-2" />
<path d="M260 130l2 5M262 135l-5-2" />
</g>
</svg>
</div>
</template>

View File

@@ -0,0 +1,11 @@
<!-- 默认轻量纸角与淡墨点不抢正文 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div
class="absolute -right-6 top-0 h-24 w-24 rotate-6 border border-lokta-deep/10 bg-gradient-to-br from-white/80 to-lokta-cream/40 shadow-[3px_4px_0_rgba(30,53,80,0.08)]"
/>
<div
class="absolute -left-4 bottom-12 h-16 w-20 -rotate-3 border border-lokta-deep/10 bg-white/70 shadow-[2px_3px_0_rgba(30,53,80,0.06)]"
/>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<!-- 粉系曼陀罗/螺旋花瓣形重复 -->
<template>
<div class="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl" aria-hidden="true">
<div class="absolute inset-0 bg-gradient-to-tr from-pink-100/70 via-rose-50/50 to-fuchsia-50/40" />
<svg class="absolute inset-0 h-full w-full opacity-85" viewBox="0 0 400 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(200 160)">
<ellipse
v-for="i in 8"
:key="i"
cx="0"
cy="-72"
rx="16"
ry="36"
:transform="`rotate(${i * 45})`"
fill="#fce7f3"
stroke="#be185d"
stroke-width="1.1"
opacity="0.75"
/>
</g>
<circle cx="200" cy="160" r="28" fill="#fff1f2" stroke="#9f1239" stroke-width="1.2" opacity="0.9" />
</svg>
</div>
</template>

View File

@@ -0,0 +1,17 @@
/**
* 网易云外链播放器:在前端配置,不经过 Django。
*
* 在 `frontend/.env` / `.env.development` 中设置:
* VITE_NETEASE_PLAYER_SRC=https://music.163.com/outchain/player?type=2&id=…&auto=1&height=66
* 也可粘贴整段 `<iframe … src="…">`,会自动取出 src。
*/
function extractIframeSrc(raw: string): string {
const t = raw.trim()
if (!t) return ''
const m = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(t)
return (m ? m[1] : t).trim()
}
const fromEnv = (import.meta.env.VITE_NETEASE_PLAYER_SRC as string | undefined)?.trim() ?? ''
export const NETEASE_PLAYER_SRC = extractIframeSrc(fromEnv)

View File

@@ -0,0 +1,82 @@
/**
* 标准尼泊尔页:按后台 nepal_art_style 切换渐变与纸纹。
* 新增风格时在此注册即可,无需改组件结构。
*/
export type NepalArtStyleKey = string
export interface StandardNepalTheme {
gradient: string
/** 额外装饰层 class点阵/叠色) */
overlayClass: string
}
export const STANDARD_NEPAL_THEMES: Record<string, StandardNepalTheme> = {
none: {
gradient: 'from-lokta-cream/90 via-paper to-lokta-blush/25',
overlayClass: 'opacity-[0.32]',
},
mandala_pink: {
gradient: 'from-lokta-blush/35 via-rose-100/25 to-lokta-cream/40',
overlayClass: 'opacity-[0.35]',
},
mandala_blue: {
gradient: 'from-cyan-100/40 via-lokta-teal/20 to-lokta-deep/10',
overlayClass: 'opacity-[0.32]',
},
spiral_rose: {
gradient: 'from-fuchsia-100/35 via-pink-50/40 to-rose-100/30',
overlayClass: 'opacity-[0.34]',
},
spiral_cyan: {
gradient: 'from-sky-100/40 via-cyan-50/35 to-teal-100/25',
overlayClass: 'opacity-[0.32]',
},
dawn_meet: {
gradient: 'from-amber-100/55 via-orange-50/45 to-rose-100/35',
overlayClass: 'opacity-[0.38]',
},
blush_argue: {
gradient: 'from-rose-200/45 via-fuchsia-100/30 to-stone-200/40',
overlayClass: 'opacity-[0.36]',
},
mint_reconcile: {
gradient: 'from-emerald-100/50 via-teal-50/40 to-cyan-100/30',
overlayClass: 'opacity-[0.33]',
},
saffron_home: {
gradient: 'from-amber-200/45 via-yellow-50/50 to-orange-100/38',
overlayClass: 'opacity-[0.36]',
},
indigo_ikea: {
gradient: 'from-indigo-100/50 via-slate-100/40 to-sky-100/38',
overlayClass: 'opacity-[0.34]',
},
lotus_petal: {
gradient: 'from-pink-100/40 via-violet-100/32 to-fuchsia-50/42',
overlayClass: 'opacity-[0.35]',
},
lokta_gold: {
gradient: 'from-yellow-100/50 via-amber-50/48 to-stone-100/42',
overlayClass: 'opacity-[0.4]',
},
ocean_prayer: {
gradient: 'from-sky-200/42 via-cyan-100/36 to-blue-50/40',
overlayClass: 'opacity-[0.33]',
},
desert_rose: {
gradient: 'from-orange-100/48 via-rose-100/36 to-amber-50/40',
overlayClass: 'opacity-[0.37]',
},
night_star: {
gradient: 'from-slate-200/40 via-indigo-200/28 to-violet-100/32',
overlayClass: 'opacity-[0.28]',
},
terra_torn: {
gradient: 'from-stone-300/40 via-amber-100/38 to-orange-200/32',
overlayClass: 'opacity-[0.38]',
},
}
export function getStandardNepalTheme(style: NepalArtStyleKey): StandardNepalTheme {
return STANDARD_NEPAL_THEMES[style] ?? STANDARD_NEPAL_THEMES.none
}

5
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

171
frontend/src/style.css Normal file
View File

@@ -0,0 +1,171 @@
@import 'tailwindcss';
@theme {
--color-paper: #faf6ef;
--color-paper-deep: #f0e8d8;
--color-paper-shadow: #e5dcc8;
--color-ink: #3d3428;
--color-ink-muted: #6b5d4f;
--color-rose-soft: #c45c6a;
--color-rose-deep: #9a3d4a;
--color-tape-pink: #f5b5c8;
--color-tape-mint: #b8e0d2;
--color-tape-cream: #f4e4bc;
/* 尼泊尔本 / 手工纸晕染 */
--color-lokta-cream: #f7f1e8;
--color-lokta-blush: #f5b8c9;
--color-lokta-peach: #fcd5ce;
--color-lokta-teal: #4ec4d4;
--color-lokta-deep: #1e3550;
--color-lokta-magenta: #d9468f;
--font-hand: 'Caveat', 'Patrick Hand', cursive;
--font-brush: 'Zhi Mang Xing', 'Ma Shan Zheng', 'Long Cang', 'PingFang SC', 'Microsoft YaHei', cursive;
--font-dance: 'Dancing Script', 'Great Vibes', 'Caveat', cursive;
--font-display-cn: 'ZCOOL QingKe HuangYou', 'Ma Shan Zheng', cursive;
--font-sans: 'Nunito', ui-sans-serif, system-ui, sans-serif;
}
.font-dance {
font-family: var(--font-dance);
}
.font-display-cn {
font-family: var(--font-display-cn);
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100dvh;
font-family: var(--font-sans);
color: var(--color-ink);
background-color: var(--color-paper);
background-image:
radial-gradient(circle at 1px 1px, rgb(61 52 40 / 0.06) 1px, transparent 0),
linear-gradient(180deg, rgb(250 246 239) 0%, rgb(245 238 225) 100%);
background-size:
24px 24px,
100% 100%;
}
#app {
min-height: 100dvh;
}
/* 毛笔标题(章节带) */
.font-brush {
font-family: var(--font-brush);
font-weight: 400;
}
/* 纤维 / 手工纸噪点 */
.nepal-fiber {
background-image:
radial-gradient(rgb(30 53 80 / 0.07) 0.6px, transparent 0.6px),
radial-gradient(rgb(196 92 106 / 0.06) 0.5px, transparent 0.5px);
background-size:
10px 12px,
14px 11px;
background-position:
0 0,
3px 5px;
}
/* 毛边纸外轮廓 */
.nepal-torn-panel {
clip-path: polygon(
0% 2%,
3% 0%,
9% 1.5%,
16% 0.3%,
24% 1.8%,
33% 0%,
42% 2%,
51% 0.4%,
60% 1.6%,
69% 0%,
78% 2%,
87% 0.5%,
95% 1.4%,
100% 2.5%,
100% 97.5%,
96% 100%,
88% 98.2%,
79% 100%,
70% 97.8%,
61% 100%,
52% 98%,
43% 100%,
34% 97.5%,
25% 100%,
16% 98%,
8% 100%,
2% 97%,
0% 94%
);
}
/* 拍立得白边轻微毛边 */
.nepal-deckle-frame {
clip-path: polygon(
1% 1.5%,
5% 0%,
12% 1.2%,
22% 0%,
35% 1.5%,
50% 0.2%,
65% 1.4%,
78% 0%,
90% 1.3%,
99% 0.5%,
100% 3%,
100% 97%,
95% 100%,
84% 98%,
70% 100%,
55% 98%,
40% 100%,
28% 98%,
15% 100%,
5% 98%,
0% 96%,
0% 4%
);
}
/* 留言区「砖墙窗」外框 */
.nepal-brick-frame {
position: relative;
padding: 11px;
border-radius: 1.5rem;
background:
linear-gradient(rgb(255 255 255 / 0.94), rgb(255 255 255 / 0.94)) padding-box,
repeating-linear-gradient(
90deg,
#9a3a36 0px,
#9a3a36 10px,
#c45a52 10px,
#c45a52 20px,
#7d2e2b 20px,
#7d2e2b 32px,
#b84a42 32px,
#b84a42 44px
)
border-box;
border: 11px solid transparent;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.65),
0 18px 50px -22px rgb(30 53 80 / 0.35);
}
.nepal-brick-frame::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 1.1rem;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgb(30 53 80 / 0.08);
}

View File

@@ -0,0 +1,38 @@
import DOMPurify from 'dompurify'
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
/**
* 将 Markdown 转为可安全 `v-html` 的 HTML。
*/
export function renderMarkdown(source: string): string {
const raw = md.render(source || '')
return DOMPurify.sanitize(raw, {
ADD_ATTR: ['target'],
ALLOWED_TAGS: [
'p',
'br',
'strong',
'em',
'u',
's',
'blockquote',
'code',
'pre',
'ul',
'ol',
'li',
'a',
'h1',
'h2',
'h3',
'h4',
'hr',
],
})
}

View File

@@ -0,0 +1,100 @@
import type { Memory, MemoryFeatureType, NepalArtStyle } from '../api/client'
/** 与 <Transition name="…"> 对应,每种一套 enter/leave 动画 */
export type StoryTransitionName =
| 'story-none'
| 'story-heart'
| 'story-fissure'
| 'story-dawn'
| 'story-blush'
| 'story-mint'
| 'story-saffron'
| 'story-indigo'
| 'story-lotus'
| 'story-rose'
| 'story-blue'
| 'story-night'
| 'story-gold'
| 'story-paper'
function transitionForStandardStyle(style: NepalArtStyle): StoryTransitionName {
const map: Partial<Record<NepalArtStyle, StoryTransitionName>> = {
dawn_meet: 'story-dawn',
blush_argue: 'story-blush',
mint_reconcile: 'story-mint',
saffron_home: 'story-saffron',
indigo_ikea: 'story-indigo',
lotus_petal: 'story-lotus',
mandala_pink: 'story-rose',
spiral_rose: 'story-rose',
mandala_blue: 'story-blue',
spiral_cyan: 'story-blue',
ocean_prayer: 'story-blue',
night_star: 'story-night',
lokta_gold: 'story-gold',
terra_torn: 'story-gold',
}
return map[style] ?? 'story-paper'
}
export function getStoryTransitionName(m: Memory | null): StoryTransitionName {
if (!m) return 'story-paper'
const t = m.unique_feature_type as MemoryFeatureType
if (t === 'heart_window') return 'story-heart'
if (t === 'fissure_page') return 'story-fissure'
return transitionForStandardStyle(m.nepal_art_style)
}
/** 书壳外框:随叙事微调描边与投影 */
export function getStoryShellClasses(m: Memory | null): string {
if (!m) {
return 'border-lokta-deep/10 shadow-[0_20px_60px_-24px_rgba(30,53,80,0.25)]'
}
const t = m.unique_feature_type
if (t === 'heart_window') {
return 'border-rose-300/35 shadow-[0_24px_70px_-22px_rgba(190,80,120,0.32)] ring-rose-200/25'
}
if (t === 'fissure_page') {
return 'border-fuchsia-300/30 shadow-[0_22px_65px_-24px_rgba(120,60,160,0.28)] ring-fuchsia-200/20'
}
const shells: Partial<Record<NepalArtStyle, string>> = {
dawn_meet:
'border-amber-200/45 shadow-[0_22px_68px_-22px_rgba(217,119,6,0.22)] ring-amber-100/30',
blush_argue:
'border-rose-200/40 shadow-[0_22px_64px_-24px_rgba(190,65,100,0.26)] ring-rose-100/25',
mint_reconcile:
'border-emerald-200/40 shadow-[0_22px_64px_-24px_rgba(16,185,129,0.2)] ring-emerald-100/25',
saffron_home:
'border-amber-300/35 shadow-[0_24px_68px_-22px_rgba(245,158,11,0.24)] ring-amber-100/30',
indigo_ikea:
'border-indigo-200/40 shadow-[0_22px_64px_-24px_rgba(79,70,229,0.22)] ring-indigo-100/25',
lotus_petal:
'border-violet-200/35 shadow-[0_24px_70px_-22px_rgba(139,92,246,0.22)] ring-violet-100/25',
night_star:
'border-slate-300/35 shadow-[0_22px_70px_-24px_rgba(51,65,85,0.35)] ring-slate-200/20',
lokta_gold:
'border-amber-200/40 shadow-[0_24px_68px_-22px_rgba(180,130,40,0.22)] ring-amber-50/35',
}
return (
shells[m.nepal_art_style] ??
'border-lokta-deep/10 shadow-[0_20px_60px_-24px_rgba(30,53,80,0.25)] ring-ink/5'
)
}
/** 顶栏上一页/下一页按钮点缀色 */
export function getStoryNavAccentClass(m: Memory | null): string {
if (!m) return 'hover:border-lokta-deep/25 hover:bg-paper-deep'
const t = m.unique_feature_type
if (t === 'heart_window') return 'hover:border-rose-300/50 hover:bg-rose-50/80'
if (t === 'fissure_page') return 'hover:border-fuchsia-300/45 hover:bg-fuchsia-50/70'
const accents: Partial<Record<NepalArtStyle, string>> = {
dawn_meet: 'hover:border-amber-300/50 hover:bg-amber-50/80',
blush_argue: 'hover:border-rose-300/45 hover:bg-rose-50/75',
mint_reconcile: 'hover:border-emerald-300/45 hover:bg-emerald-50/75',
saffron_home: 'hover:border-amber-400/40 hover:bg-amber-50/85',
indigo_ikea: 'hover:border-indigo-300/45 hover:bg-indigo-50/75',
lotus_petal: 'hover:border-violet-300/45 hover:bg-violet-50/75',
night_star: 'hover:border-slate-400/35 hover:bg-slate-100/70',
}
return accents[m.nepal_art_style] ?? 'hover:border-lokta-deep/25 hover:bg-paper-deep'
}

View File

@@ -0,0 +1,48 @@
/** 将瞬时时刻换算为「上海」日历年月日(与后端 Asia/Shanghai 叙事一致)。 */
export function ymdInShanghai(ms: number): { y: number; m: number; d: number } {
const s = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(ms))
const [y, m, d] = s.split('-').map((x) => Number.parseInt(x, 10))
return { y, m, d }
}
/** 公历某月天数month 为 112。 */
function daysInMonth(y: number, month: number): number {
return new Date(y, month, 0).getDate()
}
/**
* 从起点日历日到终点日历日的「年、月、日」差(与日常说的「在一起多久」同构)。
* 假定 a 不晚于 b。
*/
export function calendarDiffYmd(
a: { y: number; m: number; d: number },
b: { y: number; m: number; d: number },
): { years: number; months: number; days: number } {
let years = b.y - a.y
let months = b.m - a.m
let days = b.d - a.d
if (days < 0) {
months -= 1
const prevMonth = b.m === 1 ? 12 : b.m - 1
const prevYear = b.m === 1 ? b.y - 1 : b.y
days += daysInMonth(prevYear, prevMonth)
}
if (months < 0) {
years -= 1
months += 12
}
return { years, months, days }
}
/** 从起点到当前的累计小时数(可带小数,用于「相当于多少小时」)。 */
export function totalHoursBetween(startMs: number, endMs: number, fractionDigits = 1): number {
const ms = Math.max(0, endMs - startMs)
const h = ms / 3_600_000
const p = 10 ** fractionDigits
return Math.round(h * p) / p
}

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { RefreshCw } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { fetchMemories, fetchStatus, type Memory, type StatusPayload } from '../api/client'
import { NETEASE_PLAYER_SRC } from '../config/neteasePlayer'
import MessageForm from '../components/MessageForm.vue'
import NepalChapterBand from '../components/NepalChapterBand.vue'
import NepalPageContainer from '../components/NepalPageContainer.vue'
import NeteaseEmbed from '../components/NeteaseEmbed.vue'
import TogetherCounter from '../components/TogetherCounter.vue'
const status = ref<StatusPayload | null>(null)
const memories = ref<Memory[]>([])
const loadError = ref<string | null>(null)
const memoriesError = ref<string | null>(null)
const busy = ref(true)
const memoriesBusy = ref(false)
const serverOffsetMs = ref(0)
let statusPoll: ReturnType<typeof setInterval> | undefined
const siteTitle = computed(() => status.value?.site_title ?? 'Nepal Journey: Our Story')
const bgmEnabled = computed(() => status.value?.bgm_enabled ?? true)
const neteaseSrc = NETEASE_PLAYER_SRC
const showNetease = computed(() => {
if (!bgmEnabled.value || !neteaseSrc) return false
try {
let u = neteaseSrc.trim()
if (u.startsWith('//')) u = `https:${u}`
const url = new URL(u)
return url.protocol === 'https:' && url.hostname.toLowerCase() === 'music.163.com'
} catch {
return false
}
})
const showAppChrome = computed(() => !busy.value && status.value !== null)
function applyServerClock(s: StatusPayload) {
serverOffsetMs.value = Date.parse(s.server_time) - Date.now()
status.value = s
}
async function loadStatus() {
try {
loadError.value = null
const s = await fetchStatus()
applyServerClock(s)
} catch {
loadError.value = '无法连接服务器,请确认后端已启动。'
} finally {
busy.value = false
}
}
async function loadMemories() {
memoriesBusy.value = true
memoriesError.value = null
try {
memories.value = await fetchMemories()
} catch {
memoriesError.value = '回忆加载失败,请稍后重试。'
} finally {
memoriesBusy.value = false
}
}
onMounted(async () => {
await loadStatus()
if (!loadError.value) await loadMemories()
statusPoll = setInterval(() => {
void loadStatus()
}, 60_000)
})
onBeforeUnmount(() => {
if (statusPoll) clearInterval(statusPoll)
})
/** 顶栏计时器与章节纪念日期轮播(按 story_order */
const counterMilestones = computed(() =>
[...memories.value]
.sort((a, b) => a.story_order - b.story_order)
.map((m) => ({ title: m.event_title, date: m.date })),
)
</script>
<template>
<div class="relative min-h-dvh pb-8">
<div
v-if="busy"
class="flex min-h-dvh items-center justify-center text-ink-muted text-sm"
>
正在打开手帐
</div>
<template v-else>
<header
v-if="showAppChrome && status"
class="sticky top-0 z-40 border-b border-lokta-deep/10 bg-gradient-to-r from-lokta-cream/90 via-paper/90 to-lokta-teal/15 backdrop-blur-md"
>
<div class="mx-auto max-w-3xl space-y-3 px-4 py-3 sm:px-6">
<div class="min-w-0">
<p class="truncate font-brush text-2xl text-lokta-deep sm:text-3xl">
{{ siteTitle }}
</p>
<TogetherCounter
:together-since-iso="status.together_since"
:server-offset-ms="serverOffsetMs"
:milestones="counterMilestones"
/>
</div>
<NeteaseEmbed v-if="showNetease" :src="neteaseSrc" />
<p
v-else-if="bgmEnabled && !neteaseSrc"
class="rounded-lg border border-dashed border-lokta-deep/20 bg-white/50 px-3 py-2 text-xs text-ink-muted"
>
<iframe frameborder="no" border="0" marginwidth="0" marginheight="0" width=330 height=86 src="//music.163.com/outchain/player?type=2&id=2054353432&auto=1&height=66"></iframe>
</p>
</div>
</header>
<div v-if="loadError" class="mx-auto max-w-md px-6 py-10 text-center">
<p class="text-sm text-rose-deep">
{{ loadError }}
</p>
<button
type="button"
class="mt-4 inline-flex items-center gap-2 rounded-full border border-paper-shadow bg-white px-4 py-2 text-sm text-ink shadow-sm transition hover:bg-paper-deep"
@click="loadStatus"
>
<RefreshCw class="h-4 w-4" />
重试
</button>
</div>
<template v-else-if="status">
<div v-if="memoriesBusy" class="py-16 text-center text-sm text-ink-muted">
正在载入故事页
</div>
<p v-else-if="memoriesError" class="py-8 text-center text-sm text-rose-deep">
{{ memoriesError }}
</p>
<p
v-else-if="!memories.length"
class="mx-auto max-w-md px-6 py-16 text-center text-sm text-ink-muted"
>
还没有任何故事页请在项目目录运行
<code class="mt-2 block rounded bg-paper-deep px-2 py-1 text-xs text-ink">python manage.py seed_nepal_story</code>
</p>
<template v-else>
<NepalPageContainer :memories="memories" :status="status" />
<div class="mx-auto max-w-3xl px-2">
<NepalChapterBand label="最后一页 · 回信" />
<MessageForm />
</div>
</template>
</template>
</template>
</div>
</template>

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/media': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})