1. 播放器歌词效果
This commit is contained in:
Binary file not shown.
@@ -63,7 +63,7 @@ type Danmaku struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"`
|
||||
Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"`
|
||||
Color string `gorm:"column:color;size:20;not null;default:champagne;comment:弹幕颜色key" json:"color"`
|
||||
Color string `gorm:"column:color;size:32;not null;default:champagne;comment:弹幕颜色key" json:"color"`
|
||||
Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"`
|
||||
RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"`
|
||||
Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"`
|
||||
@@ -116,15 +116,21 @@ func saveModerationCache(typ, content, status, reason string) {
|
||||
}
|
||||
|
||||
var danmakuColorWhitelist = map[string]struct{}{
|
||||
"champagne": {},
|
||||
"blush": {},
|
||||
"apricot": {},
|
||||
"gold": {},
|
||||
"lilac": {},
|
||||
"sky": {},
|
||||
"mauve": {},
|
||||
"slate": {},
|
||||
"ink": {},
|
||||
"champagne": {},
|
||||
"blush": {},
|
||||
"apricot": {},
|
||||
"gold": {},
|
||||
"lilac": {},
|
||||
"sky": {},
|
||||
"mauve": {},
|
||||
"slate": {},
|
||||
"ink": {},
|
||||
"gradSunset": {},
|
||||
"gradChampagne": {},
|
||||
"gradBlush": {},
|
||||
"gradOcean": {},
|
||||
"gradAurora": {},
|
||||
"gradEmber": {},
|
||||
}
|
||||
|
||||
func normalizeDanmakuColor(color string) string {
|
||||
@@ -636,7 +642,6 @@ func main() {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -647,7 +652,7 @@ func main() {
|
||||
c.JSON(400, gin.H{"error": "请选择祝福风格"})
|
||||
return
|
||||
}
|
||||
text, err := sparkClient.GenerateBlessing(body.Name, body.Style)
|
||||
text, err := sparkClient.GenerateBlessing(body.Style)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -18,30 +18,34 @@ func NormalizeStyle(style string) (string, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (c *Client) GenerateBlessing(name, style string) (string, error) {
|
||||
func (c *Client) GenerateBlessing(style string) (string, error) {
|
||||
styleKey, ok := NormalizeStyle(style)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("无效的祝福风格")
|
||||
}
|
||||
label := BlessingStyles[styleKey]
|
||||
guest := strings.TrimSpace(name)
|
||||
guestHint := "宾客未署名"
|
||||
if guest != "" {
|
||||
guestHint = "宾客姓名:" + guest
|
||||
}
|
||||
|
||||
styleGuide := map[string]string{
|
||||
"classic": "用文言雅致、含蓄温润的古风表达,可适度用典故意象,忌生僻堆砌。",
|
||||
"modern": "用简洁真诚的现代汉语,自然得体,适合婚礼现场朗读。",
|
||||
"funny": "轻松幽默、俏皮可爱,但保持祝福善意,不低俗。",
|
||||
"roast": "损友互怼口吻,先轻度调侃再真心祝福,尺度友好不人身攻击。",
|
||||
"classic": "文言雅致、含蓄温润;可点到意象(花月、琴瑟、白首),忌生僻堆砌与整段文言文。",
|
||||
"modern": "口语真诚、干净利落;像朋友当面说的话,不鸡汤、不口号堆叠。",
|
||||
"funny": "俏皮轻松、会心一笑;可玩梗但保持善意,忌低俗与尴尬烂梗。",
|
||||
"roast": "损友口吻:先轻怼一句再落真心祝福;尺度友好,不人身攻击、不揭隐私。",
|
||||
}
|
||||
|
||||
sys := "你是婚礼祝福文案助手。只输出一条中文祝福正文,不要标题、不要解释、不要引号包裹。"
|
||||
user := fmt.Sprintf(`请写一条婚礼祝福。
|
||||
风格:%s(%s)
|
||||
%s
|
||||
要求:30到60个汉字;积极祝福新人;不要出现违法、色情、歧视或辱骂内容。`, label, styleGuide[styleKey], guestHint)
|
||||
sys := `你是婚礼短祝福文案助手。
|
||||
只输出一条可直接填写的中文祝福正文。
|
||||
禁止:标题、序号、引号包裹、解释说明、表情符号、英文、话题标签。
|
||||
禁止:出现任何真实姓名、网名、称呼占位(如某某、X先生/女士)、新郎新娘具体姓氏。
|
||||
称呼统一用「你们」「新人」等泛称即可。
|
||||
文案要有意境:有画面感与余韵,可用轻巧意象(光、风、茶、路、灯火、四季等)点染,忌空泛套话与意象堆砌。`
|
||||
|
||||
user := fmt.Sprintf(`请写一条送给婚礼新人的短祝福。
|
||||
风格:%s
|
||||
写法:%s
|
||||
意境:先落一两处可感的画面或氛围,再落到真诚祝福,读完有余味。
|
||||
篇幅:28~48个汉字,一两句说完。
|
||||
内容:祝福婚姻美满、相守与日常,语气自然,适合弹幕或回执留言。
|
||||
请直接输出祝福正文。`, label, styleGuide[styleKey])
|
||||
|
||||
return c.Chat([]Message{
|
||||
{Role: "system", Content: sys},
|
||||
|
||||
@@ -88,8 +88,8 @@ export function submitDanmaku(form) {
|
||||
return service.post('/danmaku', form)
|
||||
}
|
||||
|
||||
export function generateAiBlessing({ name, style }) {
|
||||
return service.post('/ai/blessing', { name, style }, { timeout: 45000 })
|
||||
export function generateAiBlessing({ style }) {
|
||||
return service.post('/ai/blessing', { style }, { timeout: 45000 })
|
||||
}
|
||||
|
||||
export function getDanmakuList(params) {
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="danmaku-item"
|
||||
:class="{ 'danmaku-item--grad': row.textLight }"
|
||||
:style="row.style"
|
||||
@animationend="onEnded(row.id)"
|
||||
>
|
||||
<span class="danmaku-name" :style="{ color: row.color }">{{ row.name }}</span>
|
||||
<span class="danmaku-sep" :style="{ color: row.color }">:</span>
|
||||
<span class="danmaku-text" :style="{ color: row.color }">{{ row.content }}</span>
|
||||
<span v-if="showTime && row.timeLabel" class="danmaku-time">{{ row.timeLabel }}</span>
|
||||
<span
|
||||
v-if="showTime && row.timeLabel"
|
||||
class="danmaku-time"
|
||||
:class="{ 'danmaku-time--on-grad': row.textLight }"
|
||||
>{{ row.timeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,7 +34,9 @@ const props = defineProps({
|
||||
|
||||
const POLL_MS = 30000
|
||||
const DEDUPE_MS = 30000
|
||||
const TRACKS = 6
|
||||
const TRACKS = 5
|
||||
/** 同轨占用结束前不可再发,避免重叠 */
|
||||
const TRACK_GAP_MS = 420
|
||||
|
||||
const items = ref([])
|
||||
const rows = ref([])
|
||||
@@ -41,6 +48,43 @@ let uid = 0
|
||||
const lastShownAt = new Map()
|
||||
/** 本批已飘过的业务弹幕 id */
|
||||
const shownIds = new Set()
|
||||
/** 各轨道最早可再发时间(performance.now) */
|
||||
const trackFreeAt = Array.from({ length: TRACKS }, () => 0)
|
||||
|
||||
const resetTracks = () => {
|
||||
for (let i = 0; i < TRACKS; i += 1) trackFreeAt[i] = 0
|
||||
}
|
||||
|
||||
/** @returns {{ track: number, extraDelay: number } | null} */
|
||||
const reserveTrack = (durationSec, delaySec, { force = false } = {}) => {
|
||||
const now = performance.now()
|
||||
let free = -1
|
||||
let soonest = 0
|
||||
let soonestAt = Infinity
|
||||
for (let i = 0; i < TRACKS; i += 1) {
|
||||
if (trackFreeAt[i] <= now) {
|
||||
free = i
|
||||
break
|
||||
}
|
||||
if (trackFreeAt[i] < soonestAt) {
|
||||
soonestAt = trackFreeAt[i]
|
||||
soonest = i
|
||||
}
|
||||
}
|
||||
|
||||
if (free >= 0) {
|
||||
const start = now + delaySec * 1000
|
||||
trackFreeAt[free] = start + durationSec * 1000 + TRACK_GAP_MS
|
||||
return { track: free, extraDelay: 0 }
|
||||
}
|
||||
|
||||
if (!force) return null
|
||||
|
||||
const extraDelay = Math.max(0, (soonestAt - now) / 1000)
|
||||
const start = soonestAt + delaySec * 1000
|
||||
trackFreeAt[soonest] = start + durationSec * 1000 + TRACK_GAP_MS
|
||||
return { track: soonest, extraDelay }
|
||||
}
|
||||
|
||||
const itemKey = (item) => {
|
||||
if (item?.id != null) return String(item.id)
|
||||
@@ -90,14 +134,21 @@ const markShown = (item) => {
|
||||
const buildRow = (item, { force = false } = {}) => {
|
||||
const key = itemKey(item)
|
||||
if (!force && isCooling(key)) return null
|
||||
|
||||
// 速度区间收窄,降低同屏追尾概率;轨道占用保证同轨不叠
|
||||
const duration = 12 + Math.random() * 4
|
||||
const delay = force ? 0 : Math.random() * 0.25
|
||||
const slot = reserveTrack(duration, delay, { force })
|
||||
if (!slot) return null
|
||||
|
||||
lastShownAt.set(key, Date.now())
|
||||
markShown(item)
|
||||
|
||||
const tint = resolveDanmakuTint(item.color)
|
||||
const track = Math.floor(Math.random() * TRACKS)
|
||||
const duration = 10 + Math.random() * 8
|
||||
const delay = force ? 0 : Math.random() * 0.4
|
||||
const id = ++uid
|
||||
const totalDelay = delay + slot.extraDelay
|
||||
// 5 轨均匀分布在上半屏,轨间距约 14%
|
||||
const topPct = 7 + slot.track * 14
|
||||
|
||||
const timeLabel = force
|
||||
? '刚刚'
|
||||
@@ -108,13 +159,15 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
name: item.name,
|
||||
content: item.content,
|
||||
color: resolveDanmakuColor(item.color),
|
||||
textLight: !!tint.textLight,
|
||||
timeLabel,
|
||||
style: {
|
||||
top: `${8 + track * 12}%`,
|
||||
top: `${topPct}%`,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
animationDelay: `${totalDelay}s`,
|
||||
background: tint.background,
|
||||
borderColor: tint.borderColor,
|
||||
boxShadow: tint.boxShadow,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -156,7 +209,7 @@ const fetchList = async () => {
|
||||
|
||||
const spawnOne = () => {
|
||||
if (!props.active || !props.enabled || !items.value.length) return
|
||||
if (rows.value.length >= TRACKS * 2) return
|
||||
if (rows.value.length >= TRACKS) return
|
||||
|
||||
const item = pickNextItem()
|
||||
if (!item) return
|
||||
@@ -186,7 +239,7 @@ const startSpawn = () => {
|
||||
spawnTimer = null
|
||||
if (!props.enabled || !props.active || !items.value.length) return
|
||||
spawnOne()
|
||||
spawnTimer = setInterval(spawnOne, 1800)
|
||||
spawnTimer = setInterval(spawnOne, 2200)
|
||||
}
|
||||
|
||||
const stopSpawn = () => {
|
||||
@@ -219,6 +272,7 @@ watch(
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
resetTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +292,7 @@ watch(
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
resetTracks()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -269,7 +324,7 @@ defineExpose({ refresh: fetchList, pushItem })
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 4px 12px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.28);
|
||||
box-shadow: 0 4px 14px rgba(88, 68, 42, 0.08);
|
||||
@@ -282,6 +337,11 @@ defineExpose({ refresh: fetchList, pushItem })
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
}
|
||||
.danmaku-item--grad {
|
||||
backdrop-filter: saturate(1.25) blur(8px);
|
||||
text-shadow: 0 1px 2px rgba(40, 20, 30, 0.28);
|
||||
font-weight: 500;
|
||||
}
|
||||
.danmaku-name { font-weight: 600; }
|
||||
.danmaku-sep { opacity: 0.9; }
|
||||
.danmaku-text { opacity: 0.95; }
|
||||
@@ -291,6 +351,9 @@ defineExpose({ refresh: fetchList, pushItem })
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.danmaku-time--on-grad {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
@keyframes danmaku-fly {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100vw - 100%)); }
|
||||
|
||||
@@ -38,24 +38,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 九宫格:外层仅透明占位;decode 完成后再挂边框+图,随 v-reveal 一起出现 -->
|
||||
<div v-else-if="page.type === 'nine-grid'" ref="nineGridRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
|
||||
<!-- 九宫格:轻量入场(可关);避免每格 IntersectionObserver + 双解码拖垮 iOS -->
|
||||
<div v-else-if="page.type === 'nine-grid'" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
|
||||
<div
|
||||
v-for="(img, imgIdx) in (page.images || [])"
|
||||
:key="imgIdx"
|
||||
class="aspect-square relative nine-grid-slot"
|
||||
:class="{ 'nine-grid-placeholder': imgIdx >= nineShownCount }"
|
||||
>
|
||||
<div
|
||||
v-if="imgIdx < nineShownCount"
|
||||
v-reveal="getRevealConfig(imgIdx)"
|
||||
class="absolute inset-0"
|
||||
:class="frameClass"
|
||||
v-show="imgIdx < nineShownCount"
|
||||
class="absolute inset-0 nine-grid-cell"
|
||||
:class="[frameClass, { 'nine-grid-cell--anim': nineGridAnimate, 'nine-grid-cell--in': nineGridEntered }]"
|
||||
:style="nineGridAnimate ? { transitionDelay: `${Math.min(imgIdx, 8) * 45}ms` } : undefined"
|
||||
>
|
||||
<img
|
||||
:src="img"
|
||||
:data-nine-idx="imgIdx"
|
||||
class="nine-grid-img preview-img"
|
||||
:loading="nineGridAnimate ? 'lazy' : 'eager'"
|
||||
decoding="async"
|
||||
@click.stop="previewImage(img)"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,91 +98,110 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { borderClass } from '@/composables/borderStyles'
|
||||
|
||||
const BATCH_SIZE = 3
|
||||
const BATCH_GAP_MS = 140
|
||||
const BATCH_DECODE_TIMEOUT_MS = 2500
|
||||
const STAGGER_STEP_MS = 45
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Object, required: true },
|
||||
/** 九宫格专用:父级在 section 全入视口后设为 true 才加载 */
|
||||
/** 九宫格专用:父级在 section 入视口后设为 true 才挂载展示 */
|
||||
activate: { type: Boolean, default: false },
|
||||
/** 是否开轻量入场动画;关闭时提前预加载,进入窗口即展示 */
|
||||
nineGridAnimate: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['preview-image', 'ready'])
|
||||
|
||||
const nineGridRef = ref(null)
|
||||
const nineShownCount = ref(0)
|
||||
const nineGridEntered = ref(false)
|
||||
let readyEmitted = false
|
||||
let staggerTimer = null
|
||||
let enterRaf = 0
|
||||
let preloadStarted = false
|
||||
|
||||
const frameClass = computed(() => borderClass(props.page.borderStyle))
|
||||
|
||||
const decodeImg = (img) => {
|
||||
if (!img) return Promise.resolve()
|
||||
if (typeof img.decode === 'function') return img.decode().catch(() => {})
|
||||
if (img.complete) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
img.addEventListener('load', resolve, { once: true })
|
||||
img.addEventListener('error', resolve, { once: true })
|
||||
})
|
||||
const clearStagger = () => {
|
||||
clearTimeout(staggerTimer)
|
||||
staggerTimer = null
|
||||
if (enterRaf) {
|
||||
cancelAnimationFrame(enterRaf)
|
||||
enterRaf = 0
|
||||
}
|
||||
}
|
||||
|
||||
const yieldToMain = () => new Promise((resolve) => {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => resolve())
|
||||
} else {
|
||||
setTimeout(resolve, 0)
|
||||
/** 关闭动画时提前暖缓存,进入窗口即可直接出图 */
|
||||
const preloadNineGridImages = () => {
|
||||
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
|
||||
const urls = (props.page.images || []).filter(Boolean)
|
||||
if (!urls.length) return
|
||||
preloadStarted = true
|
||||
for (const url of urls) {
|
||||
const img = new Image()
|
||||
img.decoding = 'async'
|
||||
img.src = url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 离屏预解码,完成后再抬 shownCount,避免先露白底框 */
|
||||
const preloadBatch = (urls) => Promise.race([
|
||||
Promise.allSettled(
|
||||
urls.map((url) => {
|
||||
if (!url) return Promise.resolve()
|
||||
const img = new Image()
|
||||
img.decoding = 'async'
|
||||
img.src = url
|
||||
return decodeImg(img)
|
||||
})
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, BATCH_DECODE_TIMEOUT_MS)),
|
||||
])
|
||||
const showNineGrid = async () => {
|
||||
clearStagger()
|
||||
const total = (props.page.images || []).length
|
||||
if (!total) {
|
||||
readyEmitted = true
|
||||
emit('ready')
|
||||
return
|
||||
}
|
||||
|
||||
const runBatchedReveal = async () => {
|
||||
const images = props.page.images || []
|
||||
const total = images.length
|
||||
if (!total) return
|
||||
|
||||
for (let start = 0; start < total; start += BATCH_SIZE) {
|
||||
const end = Math.min(total, start + BATCH_SIZE)
|
||||
await preloadBatch(images.slice(start, end))
|
||||
await yieldToMain()
|
||||
// decode 完再挂载:白卡纸/边框与图片一同走 reveal
|
||||
nineShownCount.value = end
|
||||
if (!props.nineGridAnimate) {
|
||||
nineShownCount.value = total
|
||||
nineGridEntered.value = true
|
||||
await nextTick()
|
||||
await yieldToMain()
|
||||
|
||||
if (end < total) {
|
||||
await new Promise((resolve) => setTimeout(resolve, BATCH_GAP_MS))
|
||||
await yieldToMain()
|
||||
}
|
||||
readyEmitted = true
|
||||
emit('ready')
|
||||
return
|
||||
}
|
||||
|
||||
// 先挂载(opacity:0),再下一帧加 --in,保证 CSS 过渡生效;无双解码 / 无 v-reveal
|
||||
nineGridEntered.value = false
|
||||
nineShownCount.value = total
|
||||
await nextTick()
|
||||
enterRaf = requestAnimationFrame(() => {
|
||||
enterRaf = requestAnimationFrame(() => {
|
||||
enterRaf = 0
|
||||
nineGridEntered.value = true
|
||||
})
|
||||
})
|
||||
const wait = Math.min(total, 9) * STAGGER_STEP_MS + 420
|
||||
staggerTimer = setTimeout(() => {
|
||||
if (readyEmitted) return
|
||||
readyEmitted = true
|
||||
emit('ready')
|
||||
}, wait)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activate && props.page?.type === 'nine-grid',
|
||||
async (on) => {
|
||||
if (!on || readyEmitted) return
|
||||
await runBatchedReveal()
|
||||
if (readyEmitted) return
|
||||
readyEmitted = true
|
||||
emit('ready')
|
||||
await showNineGrid()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.page?.type, props.nineGridAnimate, props.page?.images],
|
||||
() => {
|
||||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.nineGridAnimate) preloadNineGridImages()
|
||||
})
|
||||
|
||||
onUnmounted(clearStagger)
|
||||
|
||||
const singleWrapClass = computed(() =>
|
||||
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
|
||||
)
|
||||
@@ -226,24 +245,6 @@ function polaroidTransform(i) {
|
||||
return `rotate(${a}deg) translateY(${s}px)`
|
||||
}
|
||||
|
||||
function getRevealConfig(imgIdx) {
|
||||
const row = Math.floor(imgIdx / 3)
|
||||
const col = imgIdx % 3
|
||||
|
||||
if (imgIdx === 4) {
|
||||
return { variant: 'scale', delay: 200 }
|
||||
}
|
||||
if (col === 1) {
|
||||
return {
|
||||
variant: row === 0 ? 'down' : 'up',
|
||||
delay: 80 + imgIdx * 50,
|
||||
}
|
||||
}
|
||||
return {
|
||||
variant: col === 0 ? 'left' : 'right',
|
||||
delay: 80 + ((imgIdx + 80) * 20),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -259,21 +260,25 @@ function getRevealConfig(imgIdx) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: translateZ(0);
|
||||
transition: transform 420ms ease-out;
|
||||
backface-visibility: hidden;
|
||||
display: block;
|
||||
content-visibility: auto;
|
||||
}
|
||||
.nine-grid-img:hover { transform: translateZ(0) scale(1.03); }
|
||||
.nine-grid-slot {
|
||||
background: transparent;
|
||||
contain: layout paint;
|
||||
}
|
||||
/* 透明占位:占满格子尺寸,不露白底/边框 */
|
||||
.nine-grid-placeholder {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
.nine-grid-cell--anim {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 6px, 0);
|
||||
transition: opacity 0.36s ease, transform 0.36s ease;
|
||||
}
|
||||
.nine-grid-cell--anim.nine-grid-cell--in {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
.nine-grid-cell:not(.nine-grid-cell--anim) {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.overlap-wrap {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const DEFAULT_DANMAKU_COLOR = 'champagne'
|
||||
|
||||
/** @type {{ key: string, hex: string, label: string }[]} */
|
||||
/** @type {{ key: string, hex: string, label: string, gradient?: string, gradientBg?: string, glow?: string, textLight?: boolean }[]} */
|
||||
export const DANMAKU_COLORS = [
|
||||
{ key: 'champagne', hex: '#a88c6b', label: '香槟金' },
|
||||
{ key: 'blush', hex: '#e8b4b8', label: '浅粉' },
|
||||
@@ -13,7 +13,66 @@ export const DANMAKU_COLORS = [
|
||||
{ key: 'ink', hex: '#5c4a35', label: '墨褐' },
|
||||
]
|
||||
|
||||
const HEX_BY_KEY = Object.fromEntries(DANMAKU_COLORS.map((c) => [c.key, c.hex]))
|
||||
/** 渐变配色:高饱和多色阶 + 弹幕高显色底 + 光晕 */
|
||||
export const DANMAKU_GRADIENT_COLORS = [
|
||||
{
|
||||
key: 'gradSunset',
|
||||
hex: '#fff8f4',
|
||||
label: '晚霞',
|
||||
textLight: true,
|
||||
gradient: 'linear-gradient(125deg, #ff9f43 0%, #ff5e7a 42%, #d946ef 78%, #8b5cf6 100%)',
|
||||
gradientBg: 'linear-gradient(125deg, rgba(255,159,67,0.92) 0%, rgba(255,94,122,0.9) 42%, rgba(217,70,239,0.9) 78%, rgba(139,92,246,0.92) 100%)',
|
||||
glow: '0 6px 22px rgba(255,94,122,0.48), 0 2px 8px rgba(139,92,246,0.28)',
|
||||
},
|
||||
{
|
||||
key: 'gradChampagne',
|
||||
hex: '#fffaf0',
|
||||
label: '流金',
|
||||
textLight: true,
|
||||
gradient: 'linear-gradient(120deg, #fff1c9 0%, #f0c27a 28%, #e8a54b 55%, #c9832f 78%, #8b5a2b 100%)',
|
||||
gradientBg: 'linear-gradient(120deg, rgba(255,241,201,0.95) 0%, rgba(240,194,122,0.92) 28%, rgba(232,165,75,0.9) 55%, rgba(201,131,47,0.92) 78%, rgba(139,90,43,0.94) 100%)',
|
||||
glow: '0 6px 22px rgba(232,165,75,0.5), 0 2px 8px rgba(201,131,47,0.3)',
|
||||
},
|
||||
{
|
||||
key: 'gradBlush',
|
||||
hex: '#fff7fb',
|
||||
label: '桃雾',
|
||||
textLight: true,
|
||||
gradient: 'linear-gradient(130deg, #ff8fab 0%, #ff4d8d 35%, #e879f9 68%, #a78bfa 100%)',
|
||||
gradientBg: 'linear-gradient(130deg, rgba(255,143,171,0.92) 0%, rgba(255,77,141,0.9) 35%, rgba(232,121,249,0.9) 68%, rgba(167,139,250,0.92) 100%)',
|
||||
glow: '0 6px 22px rgba(255,77,141,0.45), 0 2px 8px rgba(167,139,250,0.3)',
|
||||
},
|
||||
{
|
||||
key: 'gradOcean',
|
||||
hex: '#f4fbff',
|
||||
label: '海暮',
|
||||
textLight: true,
|
||||
gradient: 'linear-gradient(125deg, #38bdf8 0%, #2dd4bf 32%, #6366f1 68%, #a855f7 100%)',
|
||||
gradientBg: 'linear-gradient(125deg, rgba(56,189,248,0.92) 0%, rgba(45,212,191,0.88) 32%, rgba(99,102,241,0.9) 68%, rgba(168,85,247,0.92) 100%)',
|
||||
glow: '0 6px 22px rgba(56,189,248,0.42), 0 2px 8px rgba(168,85,247,0.28)',
|
||||
},
|
||||
{
|
||||
key: 'gradAurora',
|
||||
hex: '#f8fffc',
|
||||
label: '极光',
|
||||
textLight: true,
|
||||
gradient: 'linear-gradient(120deg, #34d399 0%, #22d3ee 28%, #818cf8 58%, #f472b6 100%)',
|
||||
gradientBg: 'linear-gradient(120deg, rgba(52,211,153,0.9) 0%, rgba(34,211,238,0.88) 28%, rgba(129,140,248,0.9) 58%, rgba(244,114,182,0.92) 100%)',
|
||||
glow: '0 6px 22px rgba(34,211,238,0.4), 0 2px 10px rgba(244,114,182,0.32)',
|
||||
},
|
||||
{
|
||||
key: 'gradEmber',
|
||||
hex: '#fff8f2',
|
||||
label: '暖焰',
|
||||
textLight: true,
|
||||
gradient: 'linear-gradient(125deg, #fde047 0%, #fb923c 35%, #f43f5e 70%, #e11d48 100%)',
|
||||
gradientBg: 'linear-gradient(125deg, rgba(253,224,71,0.92) 0%, rgba(251,146,60,0.9) 35%, rgba(244,63,94,0.9) 70%, rgba(225,29,72,0.94) 100%)',
|
||||
glow: '0 6px 22px rgba(251,146,60,0.48), 0 2px 8px rgba(244,63,94,0.32)',
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_COLORS = [...DANMAKU_COLORS, ...DANMAKU_GRADIENT_COLORS]
|
||||
const BY_KEY = Object.fromEntries(ALL_COLORS.map((c) => [c.key, c]))
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace('#', '')
|
||||
@@ -22,22 +81,39 @@ function hexToRgb(hex) {
|
||||
}
|
||||
|
||||
export function resolveDanmakuColor(key) {
|
||||
if (key === 'rose' || key === 'sage') return HEX_BY_KEY[DEFAULT_DANMAKU_COLOR]
|
||||
return HEX_BY_KEY[key] || HEX_BY_KEY[DEFAULT_DANMAKU_COLOR]
|
||||
if (key === 'rose' || key === 'sage') return BY_KEY[DEFAULT_DANMAKU_COLOR].hex
|
||||
return BY_KEY[key]?.hex || BY_KEY[DEFAULT_DANMAKU_COLOR].hex
|
||||
}
|
||||
|
||||
/** 色点 / 后台圆点预览背景 */
|
||||
export function resolveDanmakuSwatch(key) {
|
||||
const entry = BY_KEY[key]
|
||||
if (!entry) return BY_KEY[DEFAULT_DANMAKU_COLOR].hex
|
||||
return entry.gradient || entry.hex
|
||||
}
|
||||
|
||||
export function resolveDanmakuTint(key) {
|
||||
const hex = resolveDanmakuColor(key)
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const entry = BY_KEY[key] || BY_KEY[DEFAULT_DANMAKU_COLOR]
|
||||
if (entry.gradientBg) {
|
||||
return {
|
||||
background: entry.gradientBg,
|
||||
borderColor: 'rgba(255, 255, 255, 0.55)',
|
||||
boxShadow: entry.glow || '0 6px 20px rgba(0,0,0,0.12)',
|
||||
textLight: !!entry.textLight,
|
||||
}
|
||||
}
|
||||
const { r, g, b } = hexToRgb(entry.hex)
|
||||
return {
|
||||
background: `rgba(${r}, ${g}, ${b}, 0.18)`,
|
||||
borderColor: `rgba(${r}, ${g}, ${b}, 0.42)`,
|
||||
boxShadow: '0 4px 14px rgba(88, 68, 42, 0.08)',
|
||||
textLight: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDanmakuColor(key) {
|
||||
if (key === 'rose' || key === 'sage') return DEFAULT_DANMAKU_COLOR
|
||||
return HEX_BY_KEY[key] ? key : DEFAULT_DANMAKU_COLOR
|
||||
return BY_KEY[key] ? key : DEFAULT_DANMAKU_COLOR
|
||||
}
|
||||
|
||||
export const BLESSING_STYLES = [
|
||||
|
||||
@@ -160,6 +160,10 @@
|
||||
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间(刚刚、x分钟前等)。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="九宫格入场动画">
|
||||
<a-switch v-model:checked="cfg.nineGridAnimate" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">关闭:提前预加载、进入窗口即展示且不暂停滚动(推荐 iOS);开启:轻量淡入并短暂停滚。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
@@ -437,7 +441,7 @@
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key==='content'">
|
||||
<span class="inline-flex items-center gap-2 min-w-0">
|
||||
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuColor(record.color) }" />
|
||||
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuSwatch(record.color) }" />
|
||||
<span class="truncate">{{ record.content }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -479,7 +483,7 @@ import {
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||||
import { resolveDanmakuColor } from '@/utils/danmakuColors'
|
||||
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
||||
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
@@ -512,6 +516,7 @@ function defaultConfig() {
|
||||
musicRandomEnabled: false,
|
||||
danmakuEnabled: true,
|
||||
danmakuShowTime: true,
|
||||
nineGridAnimate: false,
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
introExitEffect: 'wind',
|
||||
freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
|
||||
@@ -869,6 +874,7 @@ onMounted(async () => {
|
||||
if (typeof cfg.musicRandomEnabled !== 'boolean') cfg.musicRandomEnabled = false
|
||||
if (typeof cfg.danmakuEnabled !== 'boolean') cfg.danmakuEnabled = true
|
||||
if (typeof cfg.danmakuShowTime !== 'boolean') cfg.danmakuShowTime = true
|
||||
if (typeof cfg.nineGridAnimate !== 'boolean') cfg.nineGridAnimate = false
|
||||
}
|
||||
} catch (e) { /* 使用默认配置 */ }
|
||||
if (adminStore.isAdmin) {
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
type="button"
|
||||
class="action-comment"
|
||||
title="送祝福"
|
||||
@click="showTrackList = false; showBlessingDrawer = true"
|
||||
@click="showTrackList = false; openBlessingDrawer()"
|
||||
>
|
||||
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="action-comment-icon" alt="" />
|
||||
<span>写一句祝福</span>
|
||||
@@ -306,6 +306,7 @@
|
||||
<PhotoGallery
|
||||
:page="page"
|
||||
:activate="page.type === 'nine-grid' ? !!nineGridActivate[pIdx] : false"
|
||||
:nine-grid-animate="!!data.nineGridAnimate"
|
||||
@preview-image="openImagePreview"
|
||||
@ready="onNineGridReady(pIdx)"
|
||||
/>
|
||||
@@ -520,13 +521,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 送祝福抽屉 -->
|
||||
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="showBlessingDrawer = false"></div>
|
||||
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="closeBlessingDrawer"></div>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col"
|
||||
:class="{ 'pointer-events-none': !showBlessingDrawer }"
|
||||
:style="{ transform: showBlessingDrawer ? 'translateY(0)' : 'translateY(100%)' }"
|
||||
>
|
||||
<div @click="showBlessingDrawer = false" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
|
||||
<div @click="closeBlessingDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
|
||||
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">送上祝福</span>
|
||||
<span class="text-center text-[10px] text-[#8A8680] mb-8 tracking-[0.4em]">即 时 展 示</span>
|
||||
<div v-if="!blessingSubmitted" class="space-y-6">
|
||||
@@ -569,13 +570,32 @@
|
||||
@click="blessingForm.color = c.key"
|
||||
/>
|
||||
</div>
|
||||
<span class="block text-[10px] text-[#a88c6b] mb-2 mt-3 tracking-widest pl-1">渐变配色</span>
|
||||
<div class="danmaku-color-row">
|
||||
<button
|
||||
v-for="c in danmakuGradientOptions"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
class="danmaku-color-dot danmaku-color-dot--grad"
|
||||
:class="{ active: blessingForm.color === c.key }"
|
||||
:style="{ background: c.gradient }"
|
||||
:title="c.label"
|
||||
@click="blessingForm.color = c.key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="handleSubmitBlessing" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">发送祝福</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-10 flex flex-col items-center">
|
||||
<span class="text-3xl text-[#a88c6b] mb-6">♥</span>
|
||||
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">祝福已送达</span>
|
||||
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">已展示在弹幕中</span>
|
||||
<div v-else class="text-center py-8 flex flex-col items-center">
|
||||
<span class="text-3xl text-[#a88c6b] mb-5">♥</span>
|
||||
<span class="text-lg text-[#2c2c2c] mb-2 tracking-[0.2em] font-light">祝福已送达</span>
|
||||
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light mb-8">已展示在弹幕中</span>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full max-w-[280px] bg-[#a88c6b] text-white py-4 rounded-full tracking-[0.3em] text-[12px] hover:bg-[#967b5c] transition-all shadow-md"
|
||||
@click="closeBlessingDrawer"
|
||||
>确认</button>
|
||||
<span class="mt-4 text-[11px] text-[#8A8680] tracking-widest">{{ blessingCloseLeft }} 秒后自动关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -595,7 +615,7 @@ 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'
|
||||
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||||
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
|
||||
import CanvasEffects from '@/components/CanvasEffects.vue'
|
||||
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
||||
@@ -605,7 +625,7 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||
const DEFAULTS = {
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
|
||||
danmakuEnabled: true, danmakuShowTime: true,
|
||||
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
|
||||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||||
firstScreenDuration: 4500, // 首屏停留时间:首屏效果结束后再开始自动滚动
|
||||
@@ -623,6 +643,9 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
|
||||
const showOpenInBrowser = ref(false)
|
||||
const showBlessingDrawer = ref(false)
|
||||
const blessingSubmitted = ref(false)
|
||||
const blessingCloseLeft = ref(0)
|
||||
let blessingCloseTimer = null
|
||||
let blessingCloseTick = null
|
||||
const showPlayerPanel = ref(false)
|
||||
const showVolumeSlider = ref(false)
|
||||
const showPanelTrackList = ref(false)
|
||||
@@ -714,8 +737,40 @@ const nineGridHold = ref(false)
|
||||
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
||||
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
|
||||
const danmakuColorOptions = DANMAKU_COLORS
|
||||
const danmakuGradientOptions = DANMAKU_GRADIENT_COLORS
|
||||
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
|
||||
|
||||
const clearBlessingAutoClose = () => {
|
||||
clearTimeout(blessingCloseTimer)
|
||||
clearInterval(blessingCloseTick)
|
||||
blessingCloseTimer = null
|
||||
blessingCloseTick = null
|
||||
blessingCloseLeft.value = 0
|
||||
}
|
||||
|
||||
const openBlessingDrawer = () => {
|
||||
clearBlessingAutoClose()
|
||||
blessingSubmitted.value = false
|
||||
showBlessingDrawer.value = true
|
||||
}
|
||||
|
||||
const closeBlessingDrawer = () => {
|
||||
clearBlessingAutoClose()
|
||||
showBlessingDrawer.value = false
|
||||
blessingSubmitted.value = false
|
||||
}
|
||||
|
||||
const scheduleBlessingAutoClose = () => {
|
||||
clearBlessingAutoClose()
|
||||
blessingCloseLeft.value = 5
|
||||
blessingCloseTick = setInterval(() => {
|
||||
blessingCloseLeft.value = Math.max(0, blessingCloseLeft.value - 1)
|
||||
}, 1000)
|
||||
blessingCloseTimer = setTimeout(() => {
|
||||
closeBlessingDrawer()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
|
||||
let freeScrollLastTs = 0
|
||||
let nineGridObserver = null
|
||||
@@ -723,8 +778,8 @@ let nineGridRevealTimer = null
|
||||
const nineGridDone = new Set()
|
||||
const BOTTOM_RETURN_DELAY = 30000
|
||||
const FREE_SCROLL_BASE_STEP = 1.2
|
||||
/** 分批入场已占时间,收尾再略停一下即可 */
|
||||
const NINE_GRID_REVEAL_HOLD_MS = 400
|
||||
/** 开启动画时,入场结束后短暂停一下再继续自动滚 */
|
||||
const NINE_GRID_REVEAL_HOLD_ANIM_MS = 520
|
||||
const NINE_GRID_FULL_RATIO = 0.9
|
||||
|
||||
const freeInterval = computed(() => {
|
||||
@@ -938,17 +993,22 @@ const canAutoAdvance = () =>
|
||||
const beginNineGridGate = (pIdx) => {
|
||||
if (nineGridDone.has(pIdx)) return
|
||||
nineGridDone.add(pIdx)
|
||||
nineGridHold.value = true
|
||||
// 仅开启动画时暂停自动滚动;关闭动画不挡滚动
|
||||
if (data.value.nineGridAnimate) nineGridHold.value = true
|
||||
nineGridActivate[pIdx] = true
|
||||
}
|
||||
|
||||
const onNineGridReady = (pIdx) => {
|
||||
if (!nineGridDone.has(pIdx)) return
|
||||
if (!data.value.nineGridAnimate) {
|
||||
nineGridHold.value = false
|
||||
return
|
||||
}
|
||||
clearTimeout(nineGridRevealTimer)
|
||||
nineGridRevealTimer = setTimeout(() => {
|
||||
nineGridHold.value = false
|
||||
nineGridRevealTimer = null
|
||||
}, NINE_GRID_REVEAL_HOLD_MS)
|
||||
}, NINE_GRID_REVEAL_HOLD_ANIM_MS)
|
||||
}
|
||||
|
||||
const bindNineGridObserver = () => {
|
||||
@@ -957,10 +1017,13 @@ const bindNineGridObserver = () => {
|
||||
if (typeof IntersectionObserver === 'undefined') return
|
||||
const nodes = scrollContainerRef.value?.querySelectorAll('[data-nine-grid]')
|
||||
if (!nodes?.length) return
|
||||
const animate = !!data.value.nineGridAnimate
|
||||
nineGridObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.intersectionRatio < NINE_GRID_FULL_RATIO) continue
|
||||
if (!entry.isIntersecting) continue
|
||||
// 开启动画:等几乎整页入屏;关闭:进入窗口即展示
|
||||
if (animate && entry.intersectionRatio < NINE_GRID_FULL_RATIO) continue
|
||||
const raw = entry.target.getAttribute('data-nine-grid')
|
||||
if (raw == null) continue
|
||||
const pIdx = Number(raw)
|
||||
@@ -968,7 +1031,9 @@ const bindNineGridObserver = () => {
|
||||
beginNineGridGate(pIdx)
|
||||
}
|
||||
},
|
||||
{ threshold: [0, 0.85, 1] }
|
||||
animate
|
||||
? { threshold: [0, 0.85, 1] }
|
||||
: { threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
|
||||
)
|
||||
nodes.forEach((el) => nineGridObserver.observe(el))
|
||||
}
|
||||
@@ -1109,8 +1174,7 @@ const pickAiStyle = async (target, style) => {
|
||||
aiBlessingTarget.value = target
|
||||
aiBlessingLoading.value = true
|
||||
try {
|
||||
const name = target === 'rsvp' ? rsvpForm.name.trim() : blessingForm.name.trim()
|
||||
const res = await generateAiBlessing({ name, style })
|
||||
const res = await generateAiBlessing({ style })
|
||||
const text = res.data?.text || ''
|
||||
if (!text) throw new Error('未生成内容')
|
||||
if (target === 'rsvp') rsvpForm.wishes = text
|
||||
@@ -1147,6 +1211,7 @@ const handleSubmitBlessing = async () => {
|
||||
if (res.code === 200) {
|
||||
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
|
||||
blessingSubmitted.value = true
|
||||
scheduleBlessingAutoClose()
|
||||
}
|
||||
else toast.error(res.error || '提交失败')
|
||||
} catch (e) {
|
||||
@@ -1253,6 +1318,7 @@ onUnmounted(() => {
|
||||
clearTimeout(introTimer)
|
||||
clearTimeout(scrollStartTimer)
|
||||
clearBottomReturn()
|
||||
clearBlessingAutoClose()
|
||||
})
|
||||
|
||||
watch(() => data.value.scrollMode, syncPageScrollMode)
|
||||
@@ -1264,6 +1330,13 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(
|
||||
() => data.value.nineGridAnimate,
|
||||
async () => {
|
||||
await nextTick()
|
||||
bindNineGridObserver()
|
||||
}
|
||||
)
|
||||
watch(freeScrollSignature, restartAutoScroll)
|
||||
watch(
|
||||
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||
@@ -1794,6 +1867,14 @@ watch(
|
||||
box-shadow: 0 0 0 2px rgba(168,140,107,0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.danmaku-color-dot--grad {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,0.55),
|
||||
0 2px 8px rgba(88,68,42,0.18),
|
||||
0 0 12px rgba(255,140,160,0.22);
|
||||
}
|
||||
.ai-bless-wrap { position: relative; }
|
||||
.ai-bless-btn {
|
||||
font-size: 10px; letter-spacing: 0.12em; color: #a88c6b;
|
||||
|
||||
Reference in New Issue
Block a user