2026-05-11 19:34:35 +08:00
|
|
|
|
<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 MessageForm from '../components/MessageForm.vue'
|
|
|
|
|
|
import NepalChapterBand from '../components/NepalChapterBand.vue'
|
|
|
|
|
|
import NepalPageContainer from '../components/NepalPageContainer.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 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"
|
|
|
|
|
|
>
|
2026-05-20 16:31:07 +08:00
|
|
|
|
<div class="mx-auto max-w-3xl px-4 py-3 sm:px-6">
|
2026-05-11 19:34:35 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
</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>
|