1. 弹幕功能

2. 点赞
3. 导航引导
4. ai弹幕、审核
This commit is contained in:
李琦
2026-08-01 17:13:49 +08:00
parent 9950b86841
commit 142a184f57
11 changed files with 285 additions and 23 deletions

View File

@@ -1,9 +1,11 @@
<template>
<router-view />
<AppToast />
</template>
<script setup>
// 根组件:仅作为路由出口。
import AppToast from '@/components/AppToast.vue'
// 根组件:路由出口 + 全局轻提示
// 首页请柬 -> src/views/index/index.vue
// 后台登录 -> src/views/admin/AdminLogin.vue
// 内容编辑 -> src/views/admin/AdminEditor.vue

View File

@@ -0,0 +1,111 @@
<template>
<Teleport to="body">
<div class="app-toast-host" aria-live="polite" aria-relevant="additions">
<TransitionGroup name="app-toast">
<div
v-for="t in toasts"
:key="t.id"
class="app-toast"
:class="`is-${t.type}`"
role="status"
>
<span class="app-toast-icon" aria-hidden="true">
<i v-if="t.type === 'success'" class="fa-solid fa-check" />
<i v-else-if="t.type === 'error'" class="fa-solid fa-exclamation" />
<i v-else class="fa-solid fa-info" />
</span>
<p class="app-toast-msg">{{ t.message }}</p>
<button type="button" class="app-toast-close" aria-label="关闭" @click="dismiss(t.id)"></button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup>
import { useToast } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
</script>
<style scoped>
.app-toast-host {
position: fixed;
top: 18%;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: min(86vw, 340px);
pointer-events: none;
}
.app-toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 12px 14px;
border-radius: 16px;
background: rgba(253, 251, 247, 0.96);
border: 0.5px solid rgba(168, 140, 107, 0.35);
box-shadow: 0 12px 32px rgba(92, 74, 53, 0.16);
backdrop-filter: blur(8px);
color: #5c4a35;
}
.app-toast.is-success {
border-color: rgba(168, 140, 107, 0.5);
}
.app-toast.is-error {
border-color: rgba(196, 120, 110, 0.55);
background: rgba(255, 248, 246, 0.97);
}
.app-toast-icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 1px;
font-size: 11px;
background: rgba(168, 140, 107, 0.14);
color: #a88c6b;
}
.app-toast.is-error .app-toast-icon {
background: rgba(196, 120, 110, 0.16);
color: #c4786e;
}
.app-toast-msg {
flex: 1;
margin: 0;
font-size: 13px;
line-height: 1.55;
letter-spacing: 0.04em;
word-break: break-word;
}
.app-toast-close {
flex: 0 0 auto;
margin-top: -2px;
padding: 2px 4px;
font-size: 12px;
color: #9a9084;
line-height: 1;
}
.app-toast-enter-active,
.app-toast-leave-active {
transition: opacity 0.28s ease, transform 0.28s ease;
}
.app-toast-enter-from,
.app-toast-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
.app-toast-move {
transition: transform 0.28s ease;
}
</style>

View File

@@ -77,12 +77,17 @@ const buildRow = (item, { force = false } = {}) => {
const delay = force ? 0 : Math.random() * 0.4
const id = ++uid
// 发送者刚插入的弹幕强制「刚刚」,避免服务端时间解析偏差
const timeLabel = force
? '刚刚'
: formatRelativeTime(item.created_at || Date.now())
return {
id,
name: item.name,
content: item.content,
color: resolveDanmakuColor(item.color),
timeLabel: formatRelativeTime(item.created_at || Date.now()),
timeLabel,
style: {
top: `${8 + track * 12}%`,
animationDuration: `${duration}s`,
@@ -231,9 +236,9 @@ defineExpose({ refresh: fetchList, pushItem })
animation-timing-function: linear;
animation-fill-mode: forwards;
backdrop-filter: blur(6px);
max-width: min(80vw, 420px);
overflow: hidden;
text-overflow: ellipsis;
/* 不裁切宽度:长祝福时末尾相对时间需完整露出并随整条飞过 */
width: max-content;
max-width: none;
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }

View File

@@ -276,7 +276,14 @@ function getRevealConfig(imgIdx) {
box-shadow: none !important;
}
.overlap-wrap { height: clamp(320px, 54vh, 460px); }
.overlap-wrap {
width: 100%;
max-width: 420px;
margin-inline: auto;
height: clamp(360px, 56vh, 520px);
padding: 12px 16px;
box-sizing: border-box;
}
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; }

View File

@@ -0,0 +1,36 @@
import { ref } from 'vue'
const toasts = ref([])
let seq = 0
function dismiss(id) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
function show(message, type = 'info', duration = 2400) {
const text = String(message || '').trim()
if (!text) return
const id = ++seq
toasts.value = [...toasts.value, { id, message: text, type }]
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
return id
}
export function useToast() {
return {
toasts,
dismiss,
info: (msg, duration) => show(msg, 'info', duration),
success: (msg, duration) => show(msg, 'success', duration),
error: (msg, duration) => show(msg, 'error', duration),
}
}
export const toast = {
info: (msg, duration) => show(msg, 'info', duration),
success: (msg, duration) => show(msg, 'success', duration),
error: (msg, duration) => show(msg, 'error', duration),
dismiss,
}

View File

@@ -9,12 +9,33 @@ function startOfWeekMonday(d) {
return x
}
/** Parse API times; tolerate Go nano fractions and naive datetime strings. */
function parseTime(input) {
if (input instanceof Date) return input
if (typeof input === 'number') return new Date(input)
if (typeof input !== 'string') return new Date(input)
let s = input.trim()
if (!s) return new Date(NaN)
// Go RFC3339 with >3 fractional digits can break some engines — keep ms only
s = s.replace(/(\.\d{3})\d+(?=[Zz]|[+-]\d{2}:?\d{2}$)/, '$1')
// Naive "YYYY-MM-DD HH:mm:ss" → treat as local
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
s = s.replace(' ', 'T')
}
const d = new Date(s)
return d
}
/**
* Chinese relative time for danmaku:
* 刚刚 | x分钟前 | x小时前 | x天前 | 周X | 上周X | x周前 | X月X日 | X年X月X日
*/
export function formatRelativeTime(input, now = new Date()) {
const date = input instanceof Date ? input : new Date(input)
const date = parseTime(input)
if (Number.isNaN(date.getTime())) return ''
const diffMs = now.getTime() - date.getTime()

View File

@@ -462,6 +462,7 @@
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import { toast } from '@/composables/useToast'
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
@@ -806,11 +807,20 @@ const scheduleAutoScrollStart = () => {
scrollStartTimer = setTimeout(() => startAutoScroll(), firstScreenDuration.value)
}
const scrollPageToTop = () => {
window.scrollTo(0, 0)
document.documentElement.scrollTop = 0
document.body.scrollTop = 0
}
const completeIntro = () => {
if (!showIntro.value) return
clearTimeout(introTimer)
// 揭幕结束先回顶,避免落点仍在下方控件上造成误触
scrollPageToTop()
showIntro.value = false
startAutoScroll()
introOpening.value = false
scheduleAutoScrollStart()
}
const toggleAutoScroll = () => {
@@ -822,6 +832,7 @@ const toggleAutoScroll = () => {
const openInvitation = () => {
if (introOpening.value) return
clearTimeout(introTimer)
scrollPageToTop()
introOpening.value = true
introTimer = setTimeout(() => completeIntro(), introExitDuration.value)
if (data.value.musicList.length) audio.play()
@@ -894,7 +905,7 @@ const pickAiStyle = async (target, style) => {
if (target === 'rsvp') rsvpForm.wishes = text
else blessingForm.content = text
} catch (e) {
alert(e.message || 'AI 生成失败')
toast.error(e.message || 'AI 生成失败')
} finally {
aiBlessingLoading.value = false
aiBlessingTarget.value = ''
@@ -902,20 +913,20 @@ const pickAiStyle = async (target, style) => {
}
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return alert('请填写姓名')
if (!rsvpForm.name.trim()) return toast.error('请填写姓名')
try {
const res = await submitRsvp(rsvpForm)
if (res.code === 200) {
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
isSubmitted.value = true
}
else alert(res.error || '提交失败')
} catch (e) { alert(e.message || '提交失败,请检查后端运行状态') }
else toast.error(res.error || '提交失败')
} catch (e) { toast.error(e.message || '提交失败,请检查后端运行状态') }
}
const handleSubmitBlessing = async () => {
if (!blessingForm.name.trim()) return alert('请填写姓名')
if (!blessingForm.content.trim()) return alert('请填写祝福')
if (!blessingForm.name.trim()) return toast.error('请填写姓名')
if (!blessingForm.content.trim()) return toast.error('请填写祝福')
try {
const res = await submitDanmaku({
name: blessingForm.name.trim(),
@@ -926,9 +937,9 @@ const handleSubmitBlessing = async () => {
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
blessingSubmitted.value = true
}
else alert(res.error || '提交失败')
else toast.error(res.error || '提交失败')
} catch (e) {
alert(e.message || '提交失败,请检查后端运行状态')
toast.error(e.message || '提交失败,请检查后端运行状态')
}
}
@@ -950,7 +961,7 @@ const handleLike = async () => {
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
likeBurstRef.value?.burst(3)
} catch (e) {
alert(e.message || '点赞失败')
toast.error(e.message || '点赞失败')
} finally {
likeBusy.value = false
}