first commit
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
db.sqlite3
|
||||
media
|
||||
staticfiles
|
||||
.git
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV DJANGO_DEBUG=false
|
||||
ENV SQLITE_PATH=/data/db.sqlite3
|
||||
ENV MEDIA_ROOT=/data/media
|
||||
ENV DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
|
||||
ENV CORS_ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libjpeg62-turbo zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
6
backend/docker-entrypoint.sh
Normal file
6
backend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p "$(dirname "${SQLITE_PATH:-/data/db.sqlite3}")" "${MEDIA_ROOT:-/data/media}"
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
exec gunicorn ourstory.wsgi:application --bind 0.0.0.0:8000 --workers 2
|
||||
0
backend/journal/__init__.py
Normal file
0
backend/journal/__init__.py
Normal file
196
backend/journal/admin.py
Normal file
196
backend/journal/admin.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .models import Config, Guestbook, Memory, MemoryPhoto
|
||||
|
||||
|
||||
class JournalStaffAccessMixin:
|
||||
"""
|
||||
手账站点多为小项目:凡 is_staff 即可在后台增删改查(无需在「用户」里逐项勾选 journal.* 权限)。
|
||||
生产环境若需细分权限,可移除此 Mixin 并改回 Django 默认的 has_perm 校验。
|
||||
"""
|
||||
|
||||
def has_module_permission(self, request):
|
||||
u = request.user
|
||||
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
u = request.user
|
||||
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
u = request.user
|
||||
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
|
||||
|
||||
def has_add_permission(self, request):
|
||||
u = request.user
|
||||
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
u = request.user
|
||||
return bool(getattr(u, "is_active", False) and getattr(u, "is_staff", False))
|
||||
|
||||
|
||||
class MemoryPhotoInline(admin.TabularInline):
|
||||
model = MemoryPhoto
|
||||
extra = 1
|
||||
fields = ("image", "sort_order")
|
||||
|
||||
|
||||
@admin.register(Memory)
|
||||
class MemoryAdmin(JournalStaffAccessMixin, admin.ModelAdmin):
|
||||
inlines = (MemoryPhotoInline,)
|
||||
list_display = (
|
||||
"event_title",
|
||||
"story_order",
|
||||
"date",
|
||||
"unique_feature_type",
|
||||
"nepal_art_style",
|
||||
"is_finale",
|
||||
"thumb_preview",
|
||||
)
|
||||
list_display_links = ("event_title",)
|
||||
list_filter = ("unique_feature_type", "nepal_art_style", "is_finale", "date")
|
||||
list_editable = ("story_order",)
|
||||
search_fields = ("event_title", "main_text")
|
||||
readonly_fields = ("image_preview",)
|
||||
ordering = ("story_order", "id")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"叙事",
|
||||
{
|
||||
"fields": (
|
||||
"story_order",
|
||||
"date",
|
||||
"event_title",
|
||||
"is_finale",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"版式",
|
||||
{
|
||||
"fields": (
|
||||
"unique_feature_type",
|
||||
"nepal_art_style",
|
||||
"image",
|
||||
"image_preview",
|
||||
"main_text",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="缩略图")
|
||||
def thumb_preview(self, obj: Memory) -> str:
|
||||
url = None
|
||||
if obj.image:
|
||||
url = obj.image.url
|
||||
else:
|
||||
first = obj.photos.order_by("sort_order", "id").first()
|
||||
if first:
|
||||
url = first.image.url
|
||||
if not url:
|
||||
return "—"
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height:48px;border-radius:4px;" />',
|
||||
url,
|
||||
)
|
||||
|
||||
@admin.display(description="配图预览")
|
||||
def image_preview(self, obj: Memory) -> str:
|
||||
chunks: list[str] = []
|
||||
if obj.image:
|
||||
chunks.append(
|
||||
format_html(
|
||||
'<div class="mb-2"><span style="font-size:11px;color:#666">主图</span><br>'
|
||||
'<img src="{}" style="max-width:280px;border-radius:8px;" /></div>',
|
||||
obj.image.url,
|
||||
)
|
||||
)
|
||||
for p in obj.photos.order_by("sort_order", "id"):
|
||||
chunks.append(
|
||||
format_html(
|
||||
'<img src="{}" style="max-width:200px;border-radius:6px;margin:4px;" />',
|
||||
p.image.url,
|
||||
)
|
||||
)
|
||||
if not chunks:
|
||||
return "—"
|
||||
return mark_safe("".join(str(c) for c in chunks))
|
||||
|
||||
|
||||
@admin.register(Guestbook)
|
||||
class GuestbookAdmin(JournalStaffAccessMixin, admin.ModelAdmin):
|
||||
list_display = ("nickname", "short_message", "created_at")
|
||||
search_fields = ("nickname", "message")
|
||||
readonly_fields = ("created_at",)
|
||||
|
||||
@admin.display(description="留言")
|
||||
def short_message(self, obj: Guestbook) -> str:
|
||||
return obj.message[:80] + ("…" if len(obj.message) > 80 else "")
|
||||
|
||||
|
||||
@admin.register(Config)
|
||||
class ConfigAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
站点配置单独写权限:不继承 JournalStaffAccessMixin,避免 MRO/多重继承下
|
||||
has_change_permission 未按预期生效,导致 change 页全部为只读且 submit_row 不显示「保存」。
|
||||
"""
|
||||
|
||||
save_on_top = True
|
||||
|
||||
list_display = (
|
||||
"site_title",
|
||||
"together_since",
|
||||
"bgm_enabled",
|
||||
"unlock_at",
|
||||
)
|
||||
list_display_links = ("site_title",)
|
||||
fieldsets = (
|
||||
(
|
||||
"站点",
|
||||
{"fields": ("site_title",)},
|
||||
),
|
||||
(
|
||||
"时间与音乐",
|
||||
{
|
||||
"fields": (
|
||||
"together_since",
|
||||
"bgm_enabled",
|
||||
"unlock_at",
|
||||
),
|
||||
"description": (
|
||||
"「在一起」时间用于首页计数。顶部网易云播放器在前端 .env 的 "
|
||||
"VITE_NETEASE_PLAYER_SRC 配置。unlock_at 已不使用,可留空。"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def _staff_ok(self, request) -> bool:
|
||||
u = request.user
|
||||
return bool(
|
||||
getattr(u, "is_active", False)
|
||||
and getattr(u, "is_authenticated", False)
|
||||
and (getattr(u, "is_staff", False) or getattr(u, "is_superuser", False))
|
||||
)
|
||||
|
||||
def has_module_permission(self, request):
|
||||
return self._staff_ok(request)
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return self._staff_ok(request)
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return self._staff_ok(request)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
if not self._staff_ok(request):
|
||||
return False
|
||||
return not Config.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
6
backend/journal/apps.py
Normal file
6
backend/journal/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class JournalConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'journal'
|
||||
0
backend/journal/management/__init__.py
Normal file
0
backend/journal/management/__init__.py
Normal file
0
backend/journal/management/commands/__init__.py
Normal file
0
backend/journal/management/commands/__init__.py
Normal file
85
backend/journal/management/commands/seed_nepal_story.py
Normal file
85
backend/journal/management/commands/seed_nepal_story.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from datetime import date
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from journal.models import Memory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed 2026 春季时间线:遇见→争执→和好→租房→宜家,终章心窗。"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
Memory.objects.all().delete()
|
||||
|
||||
Memory.objects.create(
|
||||
story_order=10,
|
||||
date=date(2026, 4, 20),
|
||||
event_title="第一次遇见你",
|
||||
main_text=(
|
||||
"**2026 年 4 月 20 日**,故事从这里开始。\n\n"
|
||||
"相识、相见、相遇——我把这一页留给那天的风与心跳。"
|
||||
),
|
||||
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
|
||||
nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
|
||||
is_finale=False,
|
||||
)
|
||||
Memory.objects.create(
|
||||
story_order=20,
|
||||
date=date(2026, 5, 3),
|
||||
event_title="那天我们吵架了",
|
||||
main_text="旁人皆三千江水,唯你是心上涟漪。",
|
||||
unique_feature_type=Memory.FeatureType.FISSURE_PAGE,
|
||||
nepal_art_style=Memory.NepalArtStyle.BLUSH_ARGUE,
|
||||
is_finale=False,
|
||||
)
|
||||
Memory.objects.create(
|
||||
story_order=30,
|
||||
date=date(2026, 5, 4),
|
||||
event_title="和好了,在一起吧",
|
||||
main_text=(
|
||||
"**5 月 4 日**:别扭说开以后,空气又变甜了。\n\n"
|
||||
"谢谢你愿意和我把话说完。"
|
||||
),
|
||||
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
|
||||
nepal_art_style=Memory.NepalArtStyle.MINT_RECONCILE,
|
||||
is_finale=False,
|
||||
)
|
||||
Memory.objects.create(
|
||||
story_order=40,
|
||||
date=date(2026, 5, 8),
|
||||
event_title="一起租了房",
|
||||
main_text=(
|
||||
"钥匙圈上的小叮当,和阳台晒进来的光。\n\n"
|
||||
"> 这是我们共同的小宇宙开始成形的日子。"
|
||||
),
|
||||
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
|
||||
nepal_art_style=Memory.NepalArtStyle.SAFFRON_HOME,
|
||||
is_finale=False,
|
||||
)
|
||||
Memory.objects.create(
|
||||
story_order=50,
|
||||
date=date(2026, 5, 10),
|
||||
event_title="逛宜家的小日子",
|
||||
main_text=(
|
||||
"推车里的台灯、地毯和一杯热狗,"
|
||||
"都是以后会说「还记得吗」的碎片。"
|
||||
),
|
||||
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
|
||||
nepal_art_style=Memory.NepalArtStyle.INDIGO_IKEA,
|
||||
is_finale=False,
|
||||
)
|
||||
Memory.objects.create(
|
||||
story_order=90,
|
||||
date=date(2026, 5, 20),
|
||||
event_title="心墙与窗 · 终章",
|
||||
main_text=(
|
||||
"不开心就打开心墙。\n\n"
|
||||
"**我会在你身边。**\n\n"
|
||||
"—— *open when you need me*"
|
||||
),
|
||||
unique_feature_type=Memory.FeatureType.HEART_WINDOW,
|
||||
nepal_art_style=Memory.NepalArtStyle.LOTUS_PETAL,
|
||||
is_finale=True,
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded %s memories." % Memory.objects.count()))
|
||||
56
backend/journal/migrations/0001_initial.py
Normal file
56
backend/journal/migrations/0001_initial.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 10:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Config',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('unlock_at', models.DateTimeField(help_text='Story unlocks at this time (timezone-aware).')),
|
||||
('bgm_enabled', models.BooleanField(default=True)),
|
||||
('site_title', models.CharField(blank=True, default='Nepal Journey: Our Story', max_length=120)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Config',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Guestbook',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('nickname', models.CharField(max_length=80)),
|
||||
('message', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Guestbook entries',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Memory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('story_order', models.PositiveIntegerField(db_index=True, default=0, help_text='叙事顺序,数字越小越靠前。')),
|
||||
('date', models.DateField()),
|
||||
('event_title', models.CharField(max_length=200)),
|
||||
('main_text', models.TextField(blank=True, help_text='支持 Markdown。')),
|
||||
('unique_feature_type', models.CharField(choices=[('heart_window', '心窗页'), ('fissure_page', '裂缝对开页'), ('standard_nepal', '标准尼泊尔页')], default='standard_nepal', max_length=32)),
|
||||
('nepal_art_style', models.CharField(blank=True, choices=[('none', '默认'), ('mandala_pink', '粉曼陀罗'), ('mandala_blue', '蓝曼陀罗'), ('spiral_rose', '玫瑰螺旋'), ('spiral_cyan', '青色螺旋')], default='none', max_length=32)),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='memories/')),
|
||||
('is_finale', models.BooleanField(default=False, help_text='终章:在解锁时间前仅可预览占位,到点后需手动打开。')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['story_order', 'id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 10:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='memory',
|
||||
name='nepal_art_style',
|
||||
field=models.CharField(blank=True, choices=[('none', '默认米黄'), ('mandala_pink', '粉曼陀罗'), ('mandala_blue', '蓝曼陀罗'), ('spiral_rose', '玫瑰螺旋'), ('spiral_cyan', '青色螺旋'), ('dawn_meet', '晨光·遇见'), ('blush_argue', '粉雾·争执'), ('mint_reconcile', '薄荷·和好'), ('saffron_home', '姜黄·小家'), ('indigo_ikea', '靛蓝·漫游'), ('lotus_petal', '莲花瓣'), ('lokta_gold', '金边手工纸'), ('ocean_prayer', '海洋祈愿'), ('desert_rose', '沙漠玫瑰'), ('night_star', '夜空星屑'), ('terra_torn', '赭石撕边')], default='none', help_text='标准页背景/纹理;心窗与裂缝页也可作辅色参考。', max_length=48),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 10:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0002_expand_nepal_art_style_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='config',
|
||||
name='netease_player_url',
|
||||
field=models.URLField(blank=True, help_text='网易云「外链播放器」生成的地址,须以 https://music.163.com/ 开头。留空则不显示播放器。', max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='config',
|
||||
name='together_since',
|
||||
field=models.DateTimeField(blank=True, help_text='用于首页「我们在一起多少天多少秒」的起点(建议为正式在一起的时刻,含时区)。', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='config',
|
||||
name='bgm_enabled',
|
||||
field=models.BooleanField(default=True, help_text='是否展示顶部音乐区(网易云 iframe)。'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='config',
|
||||
name='unlock_at',
|
||||
field=models.DateTimeField(blank=True, help_text='已弃用:前端不再按此锁定章节,可留空。', null=True),
|
||||
),
|
||||
]
|
||||
18
backend/journal/migrations/0004_netease_player_textfield.py
Normal file
18
backend/journal/migrations/0004_netease_player_textfield.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 10:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0003_config_together_netease_unlock_nullable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='config',
|
||||
name='netease_player_url',
|
||||
field=models.TextField(blank=True, help_text='粘贴网易云外链播放器的整段 <iframe …>,或只粘贴 src 里的链接(支持 //music.163.com/…)。留空则不显示播放器。'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 11:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0004_netease_player_textfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='config',
|
||||
name='netease_player_url',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='config',
|
||||
name='bgm_enabled',
|
||||
field=models.BooleanField(default=True, help_text='是否展示顶部音乐区(播放器地址在前端环境变量 VITE_NETEASE_PLAYER_SRC 配置)。'),
|
||||
),
|
||||
]
|
||||
28
backend/journal/migrations/0006_memory_photo.py
Normal file
28
backend/journal/migrations/0006_memory_photo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-11 11:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('journal', '0005_remove_netease_from_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MemoryPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='memory_photos/')),
|
||||
('sort_order', models.PositiveSmallIntegerField(default=0, help_text='数字越小越靠前(排在主图之后)。')),
|
||||
('memory', models.ForeignKey(help_text='所属故事页', on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='journal.memory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '附加照片',
|
||||
'verbose_name_plural': '附加照片',
|
||||
'ordering': ['sort_order', 'id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/journal/migrations/__init__.py
Normal file
0
backend/journal/migrations/__init__.py
Normal file
143
backend/journal/models.py
Normal file
143
backend/journal/models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Memory(models.Model):
|
||||
"""恋爱故事页,驱动前端尼泊尔本版式。"""
|
||||
|
||||
class FeatureType(models.TextChoices):
|
||||
HEART_WINDOW = "heart_window", "心窗页"
|
||||
FISSURE_PAGE = "fissure_page", "裂缝对开页"
|
||||
STANDARD_NEPAL = "standard_nepal", "标准尼泊尔页"
|
||||
|
||||
class NepalArtStyle(models.TextChoices):
|
||||
"""每页可选不同尼泊尔视觉皮肤(备用可继续加枚举)。"""
|
||||
NONE = "none", "默认米黄"
|
||||
MANDALA_PINK = "mandala_pink", "粉曼陀罗"
|
||||
MANDALA_BLUE = "mandala_blue", "蓝曼陀罗"
|
||||
SPIRAL_ROSE = "spiral_rose", "玫瑰螺旋"
|
||||
SPIRAL_CYAN = "spiral_cyan", "青色螺旋"
|
||||
DAWN_MEET = "dawn_meet", "晨光·遇见"
|
||||
BLUSH_ARGUE = "blush_argue", "粉雾·争执"
|
||||
MINT_RECONCILE = "mint_reconcile", "薄荷·和好"
|
||||
SAFFRON_HOME = "saffron_home", "姜黄·小家"
|
||||
INDIGO_IKEA = "indigo_ikea", "靛蓝·漫游"
|
||||
LOTUS_PETAL = "lotus_petal", "莲花瓣"
|
||||
LOKTA_GOLD = "lokta_gold", "金边手工纸"
|
||||
OCEAN_PRAYER = "ocean_prayer", "海洋祈愿"
|
||||
DESERT_ROSE = "desert_rose", "沙漠玫瑰"
|
||||
NIGHT_STAR = "night_star", "夜空星屑"
|
||||
TERRA_TORN = "terra_torn", "赭石撕边"
|
||||
|
||||
story_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
db_index=True,
|
||||
help_text="叙事顺序,数字越小越靠前。",
|
||||
)
|
||||
date = models.DateField()
|
||||
event_title = models.CharField(max_length=200)
|
||||
main_text = models.TextField(
|
||||
blank=True,
|
||||
help_text="支持 Markdown。",
|
||||
)
|
||||
unique_feature_type = models.CharField(
|
||||
max_length=32,
|
||||
choices=FeatureType.choices,
|
||||
default=FeatureType.STANDARD_NEPAL,
|
||||
)
|
||||
nepal_art_style = models.CharField(
|
||||
max_length=48,
|
||||
choices=NepalArtStyle.choices,
|
||||
default=NepalArtStyle.NONE,
|
||||
blank=True,
|
||||
help_text="标准页背景/纹理;心窗与裂缝页也可作辅色参考。",
|
||||
)
|
||||
image = models.ImageField(upload_to="memories/", blank=True, null=True)
|
||||
is_finale = models.BooleanField(
|
||||
default=False,
|
||||
help_text="终章:在解锁时间前仅可预览占位,到点后需手动打开。",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["story_order", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.story_order}. {self.event_title}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.is_finale:
|
||||
Memory.objects.exclude(pk=self.pk).update(is_finale=False)
|
||||
|
||||
|
||||
class MemoryPhoto(models.Model):
|
||||
"""同一回忆页可附加多张照片;与主图 `Memory.image` 一并展示,主图在前。"""
|
||||
|
||||
memory = models.ForeignKey(
|
||||
Memory,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="photos",
|
||||
help_text="所属故事页",
|
||||
)
|
||||
image = models.ImageField(upload_to="memory_photos/")
|
||||
sort_order = models.PositiveSmallIntegerField(
|
||||
default=0,
|
||||
help_text="数字越小越靠前(排在主图之后)。",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_order", "id"]
|
||||
verbose_name = "附加照片"
|
||||
verbose_name_plural = "附加照片"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.memory_id}·{self.sort_order}"
|
||||
|
||||
|
||||
class Guestbook(models.Model):
|
||||
nickname = models.CharField(max_length=80)
|
||||
message = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name_plural = "Guestbook entries"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.nickname}: {self.message[:40]}"
|
||||
|
||||
|
||||
class Config(models.Model):
|
||||
"""Singleton-style global配置(Admin 中 id=1)。"""
|
||||
|
||||
unlock_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="已弃用:前端不再按此锁定章节,可留空。",
|
||||
)
|
||||
together_since = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="用于首页「我们在一起多少天多少秒」的起点(建议为正式在一起的时刻,含时区)。",
|
||||
)
|
||||
bgm_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="是否展示顶部音乐区(播放器地址在前端环境变量 VITE_NETEASE_PLAYER_SRC 配置)。",
|
||||
)
|
||||
site_title = models.CharField(
|
||||
max_length=120,
|
||||
default="Nepal Journey: Our Story",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Config"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Site configuration"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
pass
|
||||
51
backend/journal/serializers.py
Normal file
51
backend/journal/serializers.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Guestbook, Memory
|
||||
|
||||
|
||||
def _absolute_uri(request, path: str) -> str:
|
||||
if request is not None:
|
||||
return request.build_absolute_uri(path)
|
||||
return path
|
||||
|
||||
|
||||
class MemorySerializer(serializers.ModelSerializer):
|
||||
"""`image` 为首张 URL(兼容旧前端);`images` 为主图 + 附加图完整列表。"""
|
||||
|
||||
image = serializers.SerializerMethodField()
|
||||
images = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Memory
|
||||
fields = (
|
||||
"id",
|
||||
"story_order",
|
||||
"date",
|
||||
"event_title",
|
||||
"main_text",
|
||||
"unique_feature_type",
|
||||
"nepal_art_style",
|
||||
"image",
|
||||
"images",
|
||||
"is_finale",
|
||||
)
|
||||
|
||||
def get_images(self, obj: Memory) -> list[str]:
|
||||
request = self.context.get("request")
|
||||
out: list[str] = []
|
||||
if obj.image:
|
||||
out.append(_absolute_uri(request, obj.image.url))
|
||||
for p in obj.photos.all():
|
||||
out.append(_absolute_uri(request, p.image.url))
|
||||
return out
|
||||
|
||||
def get_image(self, obj: Memory) -> str | None:
|
||||
imgs = self.get_images(obj)
|
||||
return imgs[0] if imgs else None
|
||||
|
||||
|
||||
class GuestbookCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Guestbook
|
||||
fields = ("id", "nickname", "message", "created_at")
|
||||
read_only_fields = ("id", "created_at")
|
||||
3
backend/journal/tests.py
Normal file
3
backend/journal/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
4
backend/journal/url
Normal file
4
backend/journal/url
Normal file
@@ -0,0 +1,4 @@
|
||||
手账站:http://localhost:8080
|
||||
Admin:http://localhost:8000/admin/
|
||||
|
||||
http://localhost:5173/
|
||||
9
backend/journal/urls.py
Normal file
9
backend/journal/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import GuestbookCreateView, MemoryListView, StatusView
|
||||
|
||||
urlpatterns = [
|
||||
path("memories/", MemoryListView.as_view(), name="memory-list"),
|
||||
path("messages/", GuestbookCreateView.as_view(), name="guestbook-create"),
|
||||
path("status/", StatusView.as_view(), name="status"),
|
||||
]
|
||||
69
backend/journal/views.py
Normal file
69
backend/journal/views.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.generics import CreateAPIView, ListAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import Config, Guestbook, Memory
|
||||
from .serializers import GuestbookCreateSerializer, MemorySerializer
|
||||
|
||||
|
||||
class MemoryListView(ListAPIView):
|
||||
serializer_class = MemorySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Memory.objects.all()
|
||||
.order_by("story_order", "id")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
|
||||
class GuestbookCreateView(CreateAPIView):
|
||||
queryset = Guestbook.objects.all()
|
||||
serializer_class = GuestbookCreateSerializer
|
||||
|
||||
|
||||
class StatusView(APIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
shanghai = ZoneInfo("Asia/Shanghai")
|
||||
now = timezone.now()
|
||||
|
||||
default_together = datetime(2026, 5, 4, 0, 0, 0, tzinfo=shanghai)
|
||||
|
||||
config = Config.objects.filter(pk=1).first()
|
||||
if config is None:
|
||||
bgm_enabled = True
|
||||
site_title = "Nepal Journey: Our Story"
|
||||
together_since = default_together
|
||||
unlock_at = None
|
||||
else:
|
||||
bgm_enabled = config.bgm_enabled
|
||||
site_title = config.site_title or "Nepal Journey: Our Story"
|
||||
ts = config.together_since
|
||||
if ts is None:
|
||||
together_since = default_together
|
||||
else:
|
||||
together_since = (
|
||||
timezone.make_aware(ts, shanghai) if timezone.is_naive(ts) else ts
|
||||
)
|
||||
ua = config.unlock_at
|
||||
if ua is None:
|
||||
unlock_at = None
|
||||
else:
|
||||
unlock_at = (
|
||||
timezone.make_aware(ua, shanghai) if timezone.is_naive(ua) else ua
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"server_time": now,
|
||||
"unlocked": True,
|
||||
"unlock_at": unlock_at,
|
||||
"together_since": together_since,
|
||||
"bgm_enabled": bgm_enabled,
|
||||
"site_title": site_title,
|
||||
}
|
||||
)
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
backend/ourstory/__init__.py
Normal file
0
backend/ourstory/__init__.py
Normal file
16
backend/ourstory/asgi.py
Normal file
16
backend/ourstory/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for ourstory project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
139
backend/ourstory/settings.py
Normal file
139
backend/ourstory/settings.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Django settings for ourstory project.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get(
|
||||
"DJANGO_SECRET_KEY",
|
||||
"django-insecure-dev-only-change-in-production",
|
||||
)
|
||||
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
h.strip()
|
||||
for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
if h.strip()
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"journal",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "ourstory.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "ourstory.wsgi.application"
|
||||
|
||||
_sqlite_path = os.environ.get("SQLITE_PATH")
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": _sqlite_path if _sqlite_path else BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "zh-hans"
|
||||
|
||||
TIME_ZONE = "Asia/Shanghai"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
_media_root = os.environ.get("MEDIA_ROOT")
|
||||
MEDIA_ROOT = Path(_media_root) if _media_root else BASE_DIR / "media"
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
"OPTIONS": {
|
||||
"location": str(MEDIA_ROOT),
|
||||
"base_url": MEDIA_URL,
|
||||
},
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.AllowAny",
|
||||
],
|
||||
}
|
||||
|
||||
_cors_origins = os.environ.get(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173",
|
||||
)
|
||||
CORS_ALLOWED_ORIGINS = [o.strip() for o in _cors_origins.split(",") if o.strip()]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
o.strip() for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o.strip()
|
||||
]
|
||||
# 未配置时至少信任本机 Admin,避免部分环境下保存表单被 CSRF 拦截
|
||||
if not CSRF_TRUSTED_ORIGINS:
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://127.0.0.1:8000",
|
||||
"http://localhost:8000",
|
||||
]
|
||||
14
backend/ourstory/urls.py
Normal file
14
backend/ourstory/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.views.static import serve
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("journal.urls")),
|
||||
re_path(
|
||||
r"^media/(?P<path>.*)$",
|
||||
serve,
|
||||
{"document_root": settings.MEDIA_ROOT},
|
||||
),
|
||||
]
|
||||
16
backend/ourstory/wsgi.py
Normal file
16
backend/ourstory/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for ourstory project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ourstory.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Django>=5.0,<6
|
||||
djangorestframework>=3.15
|
||||
django-cors-headers>=4.6
|
||||
Pillow>=11.0
|
||||
gunicorn>=23.0
|
||||
whitenoise>=6.8
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal 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
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
5
frontend/.env.development
Normal file
5
frontend/.env.development
Normal 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
24
frontend/.gitignore
vendored
Normal 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
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal 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
5
frontend/README.md
Normal 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
20
frontend/index.html
Normal 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
31
frontend/nginx.conf
Normal 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
2263
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
frontend/public/audio/.gitkeep
Normal file
0
frontend/public/audio/.gitkeep
Normal file
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal 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
24
frontend/public/icons.svg
Normal 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 |
73
frontend/public/lottie/heart.json
Normal file
73
frontend/public/lottie/heart.json
Normal 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
7
frontend/src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import OurStory from './views/OurStory.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OurStory />
|
||||
</template>
|
||||
77
frontend/src/api/client.ts
Normal file
77
frontend/src/api/client.ts
Normal 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
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
112
frontend/src/components/FissurePage.vue
Normal file
112
frontend/src/components/FissurePage.vue
Normal 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>
|
||||
25
frontend/src/components/HeartMangaBackdrop.vue
Normal file
25
frontend/src/components/HeartMangaBackdrop.vue
Normal 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>
|
||||
177
frontend/src/components/HeartWindowPage.vue
Normal file
177
frontend/src/components/HeartWindowPage.vue
Normal 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>
|
||||
64
frontend/src/components/LottieSticker.vue
Normal file
64
frontend/src/components/LottieSticker.vue
Normal 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>
|
||||
93
frontend/src/components/MessageForm.vue
Normal file
93
frontend/src/components/MessageForm.vue
Normal 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>
|
||||
22
frontend/src/components/NepalChapterBand.vue
Normal file
22
frontend/src/components/NepalChapterBand.vue
Normal 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>
|
||||
46
frontend/src/components/NepalHero.vue
Normal file
46
frontend/src/components/NepalHero.vue
Normal 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>
|
||||
327
frontend/src/components/NepalPageContainer.vue
Normal file
327
frontend/src/components/NepalPageContainer.vue
Normal 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>
|
||||
44
frontend/src/components/NeteaseEmbed.vue
Normal file
44
frontend/src/components/NeteaseEmbed.vue
Normal 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>
|
||||
209
frontend/src/components/PolaroidCard.vue
Normal file
209
frontend/src/components/PolaroidCard.vue
Normal 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>
|
||||
87
frontend/src/components/StandardNepalPage.vue
Normal file
87
frontend/src/components/StandardNepalPage.vue
Normal 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>
|
||||
70
frontend/src/components/Timeline.vue
Normal file
70
frontend/src/components/Timeline.vue
Normal 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>
|
||||
174
frontend/src/components/TogetherCounter.vue
Normal file
174
frontend/src/components/TogetherCounter.vue
Normal 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>
|
||||
28
frontend/src/components/WashiTape.vue
Normal file
28
frontend/src/components/WashiTape.vue
Normal 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>
|
||||
20
frontend/src/components/standardScenes/ArgueAnimeScene.vue
Normal file
20
frontend/src/components/standardScenes/ArgueAnimeScene.vue
Normal 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>
|
||||
24
frontend/src/components/standardScenes/BlueAnimeScene.vue
Normal file
24
frontend/src/components/standardScenes/BlueAnimeScene.vue
Normal 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>
|
||||
25
frontend/src/components/standardScenes/DawnAnimeScene.vue
Normal file
25
frontend/src/components/standardScenes/DawnAnimeScene.vue
Normal 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>
|
||||
27
frontend/src/components/standardScenes/GoldAnimeScene.vue
Normal file
27
frontend/src/components/standardScenes/GoldAnimeScene.vue
Normal 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>
|
||||
20
frontend/src/components/standardScenes/HomeAnimeScene.vue
Normal file
20
frontend/src/components/standardScenes/HomeAnimeScene.vue
Normal 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>
|
||||
79
frontend/src/components/standardScenes/IkeaAnimeScene.vue
Normal file
79
frontend/src/components/standardScenes/IkeaAnimeScene.vue
Normal 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>
|
||||
29
frontend/src/components/standardScenes/LotusAnimeScene.vue
Normal file
29
frontend/src/components/standardScenes/LotusAnimeScene.vue
Normal 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>
|
||||
43
frontend/src/components/standardScenes/MangaSceneRouter.vue
Normal file
43
frontend/src/components/standardScenes/MangaSceneRouter.vue
Normal 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>
|
||||
24
frontend/src/components/standardScenes/MintAnimeScene.vue
Normal file
24
frontend/src/components/standardScenes/MintAnimeScene.vue
Normal 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>
|
||||
23
frontend/src/components/standardScenes/NightAnimeScene.vue
Normal file
23
frontend/src/components/standardScenes/NightAnimeScene.vue
Normal 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>
|
||||
11
frontend/src/components/standardScenes/PaperAnimeScene.vue
Normal file
11
frontend/src/components/standardScenes/PaperAnimeScene.vue
Normal 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>
|
||||
24
frontend/src/components/standardScenes/RoseAnimeScene.vue
Normal file
24
frontend/src/components/standardScenes/RoseAnimeScene.vue
Normal 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>
|
||||
17
frontend/src/config/neteasePlayer.ts
Normal file
17
frontend/src/config/neteasePlayer.ts
Normal 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)
|
||||
82
frontend/src/constants/nepalThemes.ts
Normal file
82
frontend/src/constants/nepalThemes.ts
Normal 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
5
frontend/src/main.ts
Normal 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
171
frontend/src/style.css
Normal 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);
|
||||
}
|
||||
38
frontend/src/utils/renderMarkdown.ts
Normal file
38
frontend/src/utils/renderMarkdown.ts
Normal 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',
|
||||
],
|
||||
})
|
||||
}
|
||||
100
frontend/src/utils/storyPageTransition.ts
Normal file
100
frontend/src/utils/storyPageTransition.ts
Normal 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'
|
||||
}
|
||||
48
frontend/src/utils/togetherDuration.ts
Normal file
48
frontend/src/utils/togetherDuration.ts
Normal 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 为 1–12。 */
|
||||
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
|
||||
}
|
||||
163
frontend/src/views/OurStory.vue
Normal file
163
frontend/src/views/OurStory.vue
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
14
frontend/tsconfig.app.json
Normal file
14
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
19
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user