first commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user