diff --git a/backend/journal/management/commands/seed_nepal_story.py b/backend/journal/management/commands/seed_nepal_story.py
index 686f46d..01227b8 100644
--- a/backend/journal/management/commands/seed_nepal_story.py
+++ b/backend/journal/management/commands/seed_nepal_story.py
@@ -75,6 +75,42 @@ class Command(BaseCommand):
nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
is_finale=False,
)
+ Memory.objects.create(
+ story_order=20260431,
+ date=date(2026, 4, 30),
+ event_title="出租牵手",
+ main_text=(
+ "**2026 年 4 月 30 日**,我们在车上开始牵手。\n\n"
+ "心跳开始加速、见面的花倒成了我们之间的障碍。"
+ ),
+ unique_feature_type=Memory.FeatureType.FIRST_MEET_TAIX,
+ nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=20260432,
+ date=date(2026, 4, 30),
+ event_title="全季M爱",
+ main_text=(
+ "**2026 年 4 月 30 日**,下次见面还是全季。\n\n"
+ "所以爱的开始就在全季,那么我们下次可以去四季嘛。"
+ ),
+ unique_feature_type=Memory.FeatureType.FIRST_MEET_QUANJI,
+ nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
+ is_finale=False,
+ )
+ Memory.objects.create(
+ story_order=20260501,
+ date=date(2026, 5, 1),
+ event_title="劳动节“劳动”",
+ main_text=(
+ "**2026 年 5 月 1 日**,劳动节我们倒是很累嘛,所以我身体真的很好吧。\n\n"
+ "从那天开始,我们就开始了劳动节的“劳动”,各种姿势,我们缠绵。"
+ ),
+ unique_feature_type=Memory.FeatureType.MAKE_LOVE,
+ nepal_art_style=Memory.NepalArtStyle.DAWN_MEET,
+ is_finale=False,
+ )
Memory.objects.create(
story_order=20260503,
date=date(2026, 5, 3),
@@ -121,7 +157,7 @@ class Command(BaseCommand):
date=date(2026, 5, 10),
event_title="逛宜家的小日子",
main_text=(
- "推车里的台灯、地毯和一杯热狗,"
+ "推车里的热狗、桌子、盘子和杯子,"
"都是以后会说「还记得吗」的碎片。"
),
unique_feature_type=Memory.FeatureType.STANDARD_NEPAL,
diff --git a/backend/journal/models.py b/backend/journal/models.py
index d35e2f1..da2f05c 100644
--- a/backend/journal/models.py
+++ b/backend/journal/models.py
@@ -12,6 +12,10 @@ class Memory(models.Model):
FISSURE_PAGE = "fissure_page", "裂缝对开页"
STANDARD_NEPAL = "standard_nepal", "标准尼泊尔页"
FIRST_MEET_TRAIN = "first_meet_train", "初见·高铁遇见"
+ FIRST_MEET_TAIX = "first_meet_taix", "初见·出租牵手"
+ FIRST_MEET_QUANJI = "first_meet_quanji", "初见·全季M爱"
+ MAKE_LOVE = "make_love", "爱爱页"
+
class NepalArtStyle(models.TextChoices):
"""每页可选不同尼泊尔视觉皮肤(备用可继续加枚举)。"""
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index d1d9493..5a6ad68 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -15,6 +15,9 @@ export type MemoryFeatureType =
| 'first_meet_ju'
| 'first_meet_rui'
| 'first_meet_train'
+ | 'first_meet_taix'
+ | 'first_meet_quanji'
+ | 'make_love'
export type NepalArtStyle =
| 'none'
diff --git a/frontend/src/components/FirstCallPage.vue b/frontend/src/components/FirstCallPage.vue
index 79cc75a..90267c6 100644
--- a/frontend/src/components/FirstCallPage.vue
+++ b/frontend/src/components/FirstCallPage.vue
@@ -1,6 +1,6 @@
@@ -47,7 +76,7 @@ const waveHeights = [18, 34, 24, 42, 28, 36, 20, 31]
/>
-
+
First Call
@@ -60,7 +89,7 @@ const waveHeights = [18, 34, 24, 42, 28, 36, 20, 31]
通话时长
-
20:00
+
{{ callTimeLabel }}
@@ -88,12 +117,22 @@ const waveHeights = [18, 34, 24, 42, 28, 36, 20, 31]
v-for="(h, i) in waveHeights"
:key="`${h}-${i}`"
class="call-wave block w-2 rounded-full bg-gradient-to-t from-cyan-500 via-sky-300 to-cyan-100"
- :class="reduceMotion === 'reduce' ? '' : `call-wave--${(i % 4) + 1}`"
+ :class="[
+ reduceMotion === 'reduce' ? '' : `call-wave--${(i % 4) + 1}`,
+ callPhase === 'idle' ? 'opacity-45' : '',
+ callPhase === 'connected' ? 'brightness-110' : '',
+ ]"
:style="{ height: `${h}px` }"
/>
- 第一次听见你的声音,原来文字之外,还有更柔软的那一层。
+ {{
+ callPhase === 'idle'
+ ? '点一下拨通,第一次电话会从文字走进声音里。'
+ : callPhase === 'dialing'
+ ? '正在接通,心跳也跟着一起变快。'
+ : '第一次听见你的声音,原来文字之外,还有更柔软的那一层。'
+ }}
@@ -106,6 +145,22 @@ const waveHeights = [18, 34, 24, 42, 28, 36, 20, 31]
{{ tag }}
+
+
+
+
diff --git a/frontend/src/components/FirstMeetCampusPage.vue b/frontend/src/components/FirstMeetCampusPage.vue
index 3ff3f5d..542c9b5 100644
--- a/frontend/src/components/FirstMeetCampusPage.vue
+++ b/frontend/src/components/FirstMeetCampusPage.vue
@@ -1,6 +1,6 @@
@@ -124,14 +129,28 @@ const morseGroups = ['-- .- -.--', '.. / .... . .- .-. -..', '.- .-.. .-.. / -.
-
- {{ line }}
-
+ {{ entry.code }}
+
+
+
+
+
+ 点一下,解码一下
+
+
{{ morseEntries[activeMorse].text }}
diff --git a/frontend/src/components/FirstMeetDmPage.vue b/frontend/src/components/FirstMeetDmPage.vue
index 65f1614..41c64fc 100644
--- a/frontend/src/components/FirstMeetDmPage.vue
+++ b/frontend/src/components/FirstMeetDmPage.vue
@@ -17,6 +17,7 @@ const props = defineProps<{
const bodyHtml = computed(() => renderMarkdown(props.memory.main_text))
const theme = computed(() => getStandardNepalTheme(props.memory.nepal_art_style))
const reduceMotion = usePreferredReducedMotion()
+const replySent = ref(false)
/** typing → shown */
const revealPhase = ref<'typing' | 'shown'>('typing')
@@ -64,6 +65,11 @@ const dmDisplayParts = computed(() => {
{ type: 'text' as const, text: s.slice(idx + '[流泪]'.length) },
]
})
+
+function toggleReply() {
+ if (revealPhase.value === 'typing') return
+ replySent.value = !replySent.value
+}
@@ -215,6 +221,32 @@ const dmDisplayParts = computed(() => {
对方已通过「视频推荐」发起会话
+
+
+
+
+ {{ revealPhase === 'typing' ? '正在输入中…' : '轻点按钮,故事就会继续一点。' }}
+
+
+
+
+
+ 读博会辛苦,但你这样认真来问的人,也会被认真回答。
+
+
@@ -335,8 +367,24 @@ const dmDisplayParts = computed(() => {
}
}
+.fm-reply-pop {
+ animation: fm-reply-pop 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
+ transform-origin: top right;
+}
+
+@keyframes fm-reply-pop {
+ 0% {
+ opacity: 0;
+ transform: translateY(12px) scale(0.92);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
/* 撕纸边缘容器补充 */
.nepal-torn-panel {
border-radius: 4px 4px 16px 16px;
}
-
\ No newline at end of file
+
diff --git a/frontend/src/components/FissurePage.vue b/frontend/src/components/FissurePage.vue
index 0c1049c..10ba880 100644
--- a/frontend/src/components/FissurePage.vue
+++ b/frontend/src/components/FissurePage.vue
@@ -13,6 +13,7 @@ const stageRef = ref(null)
const split = ref(0)
const reduceMotion = usePreferredReducedMotion()
const bodyHtml = computed(() => renderMarkdown(props.memory.main_text))
+const panelShift = computed(() => `${split.value * 72}%`)
const hurtFacts = [
'我把你和前任之间的事情告诉了别人',
@@ -43,7 +44,7 @@ usePointerSwipe(stageRef, {
const isOpen = computed(() => split.value > 0.35)
function toggleSplit() {
- split.value = isOpen.value ? 0 : 0.85
+ split.value = isOpen.value ? 0 : 1
}
const splitHint = computed(() =>
@@ -156,7 +157,7 @@ const fracturePulseClass = computed(() => {
{
import { usePreferredReducedMotion } from '@vueuse/core'
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
import type { Memory } from '../api/client'
import { getStandardNepalTheme } from '../constants/nepalThemes'
import PolaroidCard from './PolaroidCard.vue'
@@ -13,6 +13,7 @@ const props = defineProps<{
const bodyHtml = computed(() => renderMarkdown(props.memory.main_text))
const theme = computed(() => getStandardNepalTheme(props.memory.nepal_art_style))
const reduceMotion = usePreferredReducedMotion()
+const homeUnlocked = ref(false)
@@ -59,7 +60,10 @@ const reduceMotion = usePreferredReducedMotion()
@@ -80,9 +84,13 @@ const reduceMotion = usePreferredReducedMotion()
-
+
@@ -106,6 +114,10 @@ const reduceMotion = usePreferredReducedMotion()
{{ tag }}
+
+
+ {{ homeUnlocked ? '钥匙轻轻一转,这个“共同生活”的按钮就真的按下去了。' : '点一下钥匙圈,试着把这间小屋先打开。' }}
+
diff --git a/frontend/src/components/IkeaDatePage.vue b/frontend/src/components/IkeaDatePage.vue
index 26b61d6..8102a8a 100644
--- a/frontend/src/components/IkeaDatePage.vue
+++ b/frontend/src/components/IkeaDatePage.vue
@@ -1,6 +1,6 @@
@@ -60,6 +115,15 @@ const reduceMotion = usePreferredReducedMotion()
SALE
+
+
+ {{ flyingItem }}
+
+
@@ -74,9 +138,16 @@ const reduceMotion = usePreferredReducedMotion()
Cart List
- 台灯
- 地毯
- 热狗
+
@@ -86,6 +157,9 @@ const reduceMotion = usePreferredReducedMotion()
class="relative mx-auto h-32 w-[180px]"
:class="reduceMotion === 'reduce' ? '' : 'ikea-cart-bob'"
>
+
+ {{ activeItems.length }}
+
@@ -95,19 +169,49 @@ const reduceMotion = usePreferredReducedMotion()
+
+ {{ entry.item }}
+
{{ tag }}
+
+
+
+
+ {{
+ activeItems.length
+ ? `推车里现在有:${activeItems.join('、')}`
+ : '轻点一下,把今天想带走的小碎片先放进去。'
+ }}
+
+
@@ -133,6 +237,32 @@ const reduceMotion = usePreferredReducedMotion()
animation: ikea-cart-bob 4.6s ease-in-out infinite;
}
+.ikea-fly-enter-active {
+ animation: ikea-fly-into-cart 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+.ikea-fly-leave-active {
+ transition: opacity 0.14s linear;
+}
+
+.ikea-fly-leave-to {
+ opacity: 0;
+}
+
+@keyframes ikea-fly-into-cart {
+ 0% {
+ opacity: 0;
+ transform: translate(0, 0) scale(0.88) rotate(-6deg);
+ }
+ 16% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(168px, 92px) scale(0.74) rotate(11deg);
+ }
+}
+
@keyframes ikea-cart-bob {
0%,
100% {
diff --git a/frontend/src/components/LuckinCoffeePage.vue b/frontend/src/components/LuckinCoffeePage.vue
index 4be0364..e834d59 100644
--- a/frontend/src/components/LuckinCoffeePage.vue
+++ b/frontend/src/components/LuckinCoffeePage.vue
@@ -1,6 +1,6 @@
@@ -93,9 +94,23 @@ const reduceMotion = usePreferredReducedMotion()
class="absolute bottom-5 left-1/2 w-[85%] -translate-x-1/2 rounded-[1.2rem] border border-white/70 bg-white/72 px-3 py-2 shadow-[0_10px_25px_rgba(255,255,255,0.45)] backdrop-blur-sm"
>
+
+
咖啡
- 橙C
- 灵笼
@@ -108,14 +123,24 @@ const reduceMotion = usePreferredReducedMotion()
Receipt
-
- 生椰拿铁
- x 1
-
-
+
+
+
remembered
+
记住你的喜好
1 次
@@ -124,14 +149,19 @@ const reduceMotion = usePreferredReducedMotion()
-
-
- Desk Corner
+
+
+ {{ focusDetail === 'orange' ? 'Orange C' : 'Desk Corner' }}
- 你连桌角的小小“灵笼”都看见了。喜欢有时候不是一句话,是被认真注意到。
+ {{
+ focusDetail === 'orange'
+ ? '你记住了我会点橙C。喜欢有时候不是轰轰烈烈,是被记住这一点点。'
+ : '你连桌角的小小“灵笼”都看见了。喜欢有时候不是一句话,是被认真注意到。'
+ }}
diff --git a/frontend/src/components/NepalPageContainer.vue b/frontend/src/components/NepalPageContainer.vue
index 5758b8f..04e2f01 100644
--- a/frontend/src/components/NepalPageContainer.vue
+++ b/frontend/src/components/NepalPageContainer.vue
@@ -15,9 +15,12 @@ import FissurePage from './FissurePage.vue'
import HeartWindowPage from './HeartWindowPage.vue'
import HomeRentalPage from './HomeRentalPage.vue'
import IkeaDatePage from './IkeaDatePage.vue'
+import LaborDayPage from './LaborDayPage.vue'
import LuckinCoffeePage from './LuckinCoffeePage.vue'
+import QuanjiPage from './QuanjiPage.vue'
import ReconcilePage from './ReconcilePage.vue'
import StandardNepalPage from './StandardNepalPage.vue'
+import TaxiHandHoldPage from './TaxiHandHoldPage.vue'
const props = defineProps<{
memories: Memory[]
@@ -49,6 +52,9 @@ const pageComponent = computed(() => {
20260423: LuckinCoffeePage,
20260424: FirstCallPage,
20260430: FirstMeetTrainPage,
+ 20260431: TaxiHandHoldPage,
+ 20260432: QuanjiPage,
+ 20260501: LaborDayPage,
20260503: FissurePage,
20260504: ReconcilePage,
20260508: HomeRentalPage,
@@ -68,6 +74,9 @@ const pageComponent = computed(() => {
return memory.event_title.includes('电话') ? FirstCallPage : LuckinCoffeePage
}
if (t === 'first_meet_train') return FirstMeetTrainPage
+ if (t === 'first_meet_taix') return TaxiHandHoldPage
+ if (t === 'first_meet_quanji') return QuanjiPage
+ if (t === 'make_love') return LaborDayPage
return StandardNepalPage
})
@@ -180,6 +189,8 @@ function next() {
.story-fissure-leave-active,
.story-dawn-enter-active,
.story-dawn-leave-active,
+.story-silk-enter-active,
+.story-silk-leave-active,
.story-blush-enter-active,
.story-blush-leave-active,
.story-mint-enter-active,
@@ -245,6 +256,18 @@ function next() {
filter: brightness(0.92);
}
+/* 亲密页 · 被单与呼吸感 */
+.story-silk-enter-from {
+ opacity: 0;
+ transform: translateY(20px) scale(0.96);
+ filter: blur(2px) saturate(1.08);
+}
+.story-silk-leave-to {
+ opacity: 0;
+ transform: scale(1.03);
+ filter: blur(1px) brightness(1.04);
+}
+
/* 别扭 · 粉雾 */
.story-blush-enter-from {
opacity: 0;
diff --git a/frontend/src/components/ReconcilePage.vue b/frontend/src/components/ReconcilePage.vue
index a39d048..b69e910 100644
--- a/frontend/src/components/ReconcilePage.vue
+++ b/frontend/src/components/ReconcilePage.vue
@@ -1,6 +1,6 @@
@@ -58,7 +59,10 @@ const reduceMotion = usePreferredReducedMotion()
Before
@@ -69,7 +73,13 @@ const reduceMotion = usePreferredReducedMotion()
♡
@@ -78,7 +88,10 @@ const reduceMotion = usePreferredReducedMotion()
After
@@ -98,6 +111,19 @@ const reduceMotion = usePreferredReducedMotion()
+
+
+
+
+ {{ talkedItOut ? '说开以后,卡在中间的那口气终于顺了。' : '轻点一下,让“Before”和“After”靠近一点。' }}
+
+
diff --git a/frontend/src/utils/storyPageTransition.ts b/frontend/src/utils/storyPageTransition.ts
index 29c62f8..632e115 100644
--- a/frontend/src/utils/storyPageTransition.ts
+++ b/frontend/src/utils/storyPageTransition.ts
@@ -6,6 +6,7 @@ export type StoryTransitionName =
| 'story-heart'
| 'story-fissure'
| 'story-dawn'
+ | 'story-silk'
| 'story-blush'
| 'story-mint'
| 'story-saffron'
@@ -43,7 +44,10 @@ export function getStoryTransitionName(m: Memory | null): StoryTransitionName {
if (t === 'heart_window') return 'story-heart'
if (t === 'fissure_page') return 'story-fissure'
if (t === 'first_meet' || t === 'first_meet_ju' || t === 'first_meet_rui') return 'story-night'
- if (t === 'first_meet_train') return 'story-dawn'
+ if (t === 'first_meet_train' || t === 'first_meet_taix' || t === 'first_meet_quanji') {
+ return 'story-dawn'
+ }
+ if (t === 'make_love') return 'story-silk'
return transitionForStandardStyle(m.nepal_art_style)
}
@@ -62,9 +66,12 @@ export function getStoryShellClasses(m: Memory | null): string {
if (t === 'first_meet' || t === 'first_meet_ju' || t === 'first_meet_rui') {
return 'border-slate-400/35 shadow-[0_24px_70px_-22px_rgba(30,58,138,0.28)] ring-cyan-900/15'
}
- if (t === 'first_meet_train') {
+ if (t === 'first_meet_train' || t === 'first_meet_taix' || t === 'first_meet_quanji') {
return 'border-amber-200/50 shadow-[0_24px_72px_-22px_rgba(234,88,12,0.24)] ring-amber-100/35'
}
+ if (t === 'make_love') {
+ return 'border-rose-200/45 shadow-[0_24px_72px_-22px_rgba(190,80,120,0.28)] ring-rose-100/25'
+ }
const shells: Partial> = {
dawn_meet:
'border-amber-200/45 shadow-[0_22px_68px_-22px_rgba(217,119,6,0.22)] ring-amber-100/30',
@@ -98,7 +105,10 @@ export function getStoryNavAccentClass(m: Memory | null): string {
if (t === 'first_meet' || t === 'first_meet_ju' || t === 'first_meet_rui') {
return 'hover:border-slate-400/45 hover:bg-slate-100/85'
}
- if (t === 'first_meet_train') return 'hover:border-amber-300/50 hover:bg-amber-50/80'
+ if (t === 'first_meet_train' || t === 'first_meet_taix' || t === 'first_meet_quanji') {
+ return 'hover:border-amber-300/50 hover:bg-amber-50/80'
+ }
+ if (t === 'make_love') return 'hover:border-rose-300/50 hover:bg-rose-50/80'
const accents: Partial> = {
dawn_meet: 'hover:border-amber-300/50 hover:bg-amber-50/80',
blush_argue: 'hover:border-rose-300/45 hover:bg-rose-50/75',