隨著日子一天天過去,瓶底積累了每天最幸福的痕跡,以及忙著生活的象徵。如果只是任由它們堆在瓶底,是不是少了一點儀式感。
今天我們要實作一個重磅功能:在月底最後一天放入最後一顆微光時,軟木塞將被打開,所有光珠像是流星一顆顆飛出瓶口,在半空中聚攏凝結成一張專屬於該月份的「微光總結色卡」!
為了讓月度回顧這個功能不只充滿儀式感,還要符合邏輯,我們梳理出四個細節:
radial-gradient 與高斯模糊暈染當月色彩);下半部為留白區,包含微光總數與色票圓點。今天的程式碼有很多動畫的呈現,所以篇幅比較長,但程式碼都有加上註解所以不用擔心會很複雜。
<script setup>:時間判定與流星儀式排程我們在 <script setup> 補上月底判定、隔月 1 號補領,以及動態計算每顆光珠飛出時間的函式:
// 控制月度色卡與光珠飛出瓶口動畫狀態
const showMonthlyCard = ref(false)
const isShootingStars = ref(false)
const cardMonthTarget = ref(todayStr.slice(0, 7))
// 1. 防呆:判斷今天是否已經採集過微光
const hasCollectedToday = computed(() => {
return gems.value.some(g => g.date === todayStr && !g.isAutoGenerated)
})
// 2. 判斷某日期是否為該月最後一天(下個月第 0 天就是本月最後一天)
function isLastDayOfMonth(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number)
return day === new Date(year, month, 0).getDate()
}
// 3. 判斷某日期是否為該月 1 號
function isFirstDayOfMonth(dateStr) {
const [, , day] = dateStr.split('-').map(Number)
return day === 1
}
// 取得上個月的 "YYYY-MM" 字串
function getPreviousMonthStr(dateStr) {
const [year, month] = dateStr.split('-').map(Number)
const prevDate = new Date(year, month - 2, 1)
const prevYear = prevDate.getFullYear()
const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0')
return `${prevYear}-${prevMonth}`
}
// 判斷是否為 1 號,且上個月有微光可以補回顧
const canClaimPreviousMonth = computed(() => {
if (!isFirstDayOfMonth(todayStr)) return false
const prevMonth = getPreviousMonthStr(todayStr)
return gems.value.some(g => g.date && g.date.startsWith(prevMonth))
})
// 4. 觸發儀式:瓶蓋彈開 ➔ 一顆顆光珠飛出 ➔ 變成色卡
function triggerShootingStarsRitual(monthTarget = todayStr.slice(0, 7)) {
if (gems.value.length === 0) {
cardMonthTarget.value = monthTarget
showMonthlyCard.value = true
return
}
cardMonthTarget.value = monthTarget
isShootingStars.value = true
// 瓶蓋等待 0.55s + (顆數 * 0.08s) + 飛行時間 0.75s
const totalDuration = 550 + gems.value.length * 80 + 750
setTimeout(() => {
isShootingStars.value = false
showMonthlyCard.value = true
}, totalDuration)
}
在採集光珠的 addGem 函式中,加入防呆與月底觸發邏輯:
function addGem() {
if (todayNote.value.trim() === '') {
alert('請先寫下一件今天的美好小事喔!')
return
}
// ... 加入光珠資料至 gems ...
// 若適逢月底最後一天,放入光珠時觸發儀式
if (isLastDayOfMonth(todayStr)) {
triggerShootingStarsRitual(todayStr.slice(0, 7))
} else {
isGlowing.value = true
setTimeout(() => { isGlowing.value = false }, 1000)
}
}
接著運算當月色彩與色卡多重漸層背景:
// 篩選目標月份的所有光珠與不重複色彩
const targetMonthGems = computed(() => {
return gems.value.filter(g => g.date && g.date.startsWith(cardMonthTarget.value))
})
const monthColors = computed(() => {
const colors = targetMonthGems.value.map(g => g.color)
return Array.from(new Set(colors))
})
// 動態運算漸層背景
const monthlyGradientStyle = computed(() => {
const colors = monthColors.value
if (colors.length === 0) {
return { background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)' }
}
const anchorPoints = ['20% 25%', '80% 75%', '85% 20%', '15% 80%', '50% 50%']
const radialLayers = colors.map((col, idx) => {
const pos = anchorPoints[idx % anchorPoints.length]
const renderColor = col.toLowerCase() === '#ffffff' ? 'rgba(255, 255, 255, 0.85)' : col
return `radial-gradient(circle at ${pos}, ${renderColor} 0%, transparent 65%)`
})
return {
backgroundImage: radialLayers.join(', '),
backgroundColor: '#11161d'
}
})
<template>:流星出瓶結構與月度色卡在樣板中,主要為軟木塞加上彈飛 class、為光珠設定由上而下單顆錯開的延遲時間,並放入月度色卡彈窗:
<!-- 隔月 1 號補領提醒條 -->
<div v-if="canClaimPreviousMonth" class="catchup-banner">
<span>新月份開始了!上個月的記憶已經總結。</span>
<button class="btn-catchup" @click="cardMonthTarget = getPreviousMonthStr(todayStr); showMonthlyCard = true">
回顧上月色卡
</button>
</div>
<!-- 瓶身與光珠飛行軌跡區域 -->
<div class="bottle-wrapper">
<!-- 軟木塞:彈飛動畫 -->
<div class="cork" :class="{ 'is-popping': isShootingStars }"></div>
<div class="bottle" :class="{ 'is-bursting': isShootingStars }">
<div class="gem-pile">
<div
v-for="(gem, index) in gems"
:key="gem.id"
class="gem-dot"
:class="{ 'is-shooting-star': isShootingStars }"
:style="{
backgroundColor: gem.color,
'--offset-x': `${gem.offsetX}px`,
'--offset-y': `${gem.offsetY}px`,
/* 由最上層一顆一顆錯開 0.08 秒 */
'--star-delay': `${0.55 + (gems.length - 1 - index) * 0.08}s`
}"
@click="openGem(gem)"
></div>
</div>
</div>
</div>
<!-- 手動預覽按鈕(開發測試也能隨時查看) -->
<button class="btn-monthly-trigger" @click="triggerShootingStarsRitual()">
查看本月微光色卡
</button>
<!-- 防呆核心:文字輸入區域(已採集則鎖定) -->
<div class="input-group" :class="{ 'is-disabled': hasCollectedToday }">
<input
id="todayNote"
name="todayNote"
v-model="todayNote"
type="text"
maxlength="20"
:placeholder="hasCollectedToday ? '今天的美好已安放,明天見~' : '寫下一件微小的美好...'"
:disabled="hasCollectedToday"
class="note-input"
/>
<span class="char-count" v-if="!hasCollectedToday">{{ 20 - todayNote.length }} / 20</span>
</div>
<!-- 防呆核心:採集按鈕(已採集則反灰並禁用) -->
<button
class="btn-collect"
:class="{ 'btn-disabled': hasCollectedToday }"
:style="{ backgroundColor: hasCollectedToday ? '#30363d' : selectedColor }"
:disabled="hasCollectedToday"
@click="addGem"
>
{{ hasCollectedToday ? '今日微光已入瓶' : '放入這道微光' }}
</button>
<!-- 月度色卡彈窗 -->
<div v-if="showMonthlyCard" class="modal-backdrop" @click="showMonthlyCard = false">
<div class="monthly-art-card" @click.stop>
<!-- 上半部:畫布主體 -->
<div class="aurora-frame" :style="monthlyGradientStyle">
<div class="aurora-blur-glass"></div>
<span class="frame-tag">{{ cardMonthTarget }} MOOD PALETTE</span>
</div>
<!-- 下半部:拍立得留白區 -->
<div class="card-meta">
<h3 class="meta-title">月度微光色卡</h3>
<p class="meta-stats">{{ cardMonthTarget }} 共凝聚了 {{ targetMonthGems.length }} 道微光</p>
<div class="palette-swatches">
<span
v-for="(col, index) in monthColors"
:key="index"
class="swatch-bubble"
:style="{ backgroundColor: col }"
></span>
</div>
<p class="meta-whisper">「每一道色彩,都是這個月認真生活留下的印記」</p>
<button class="btn-seal" @click="showMonthlyCard = false">珍藏這張月卡</button>
</div>
</div>
</div>
<style scoped>:光珠飛出瓶口外這是今天最精彩的視覺核心。
/* 軟木塞彈飛動畫 */
.cork.is-popping {
animation: corkPopOut 0.85s cubic-bezier(0.15, 0.85, 0.35, 1) forwards;
}
@keyframes corkPopOut {
0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
35% { transform: translate(8px, -45px) rotate(25deg); }
70% { transform: translate(65px, -110px) rotate(110deg); opacity: 0.85; }
100% { transform: translate(110px, -140px) rotate(200deg); opacity: 0; }
}
/* 光珠筆直往瓶口飛出 */
.gem-dot.is-shooting-star {
animation: singleGemStraightOut 0.75s cubic-bezier(0.4, 0, 0.2, 1) forwards;
animation-delay: var(--star-delay, 0s);
pointer-events: none;
}
@keyframes singleGemStraightOut {
0% {
transform: translate(var(--offset-x, 0), var(--offset-y, 0)) scale(1);
opacity: 1;
}
100% {
transform: translate(0px, -260px) scale(0.2);
opacity: 0;
box-shadow: 0 0 20px currentColor, 0 0 30px #ffffff;
}
}
/* 拍立得色卡彈出動畫 */
.monthly-art-card {
width: 280px;
background: #ffffff;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
animation: cardCondense 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes cardCondense {
0% { transform: scale(0.65) translateY(-40px); opacity: 0; filter: brightness(2); }
100% { transform: scale(1) translateY(0); opacity: 1; filter: brightness(1); }
}
/* 畫布與高斯模糊 */
.aurora-frame {
width: 100%;
height: 220px;
position: relative;
overflow: hidden;
}
.aurora-blur-glass {
position: absolute;
inset: 0;
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
}
/* 輸入框與按鈕的鎖定外觀 */
.input-group.is-disabled {
opacity: 0.45;
cursor: not-allowed;
border-style: dashed;
}
.btn-collect.btn-disabled {
color: #8b949e;
cursor: not-allowed;
box-shadow: none;
}
這些內容我們等明天再測試,測試完剛好可以直接模擬不同月份的假資料,來驗證另一個重要細節:「情緒色彩佔比」。
看著眼前的月度色卡,細心的你可能已經發現了一個問題:
「如果我們的心情顏色固定就是那幾種,每個月大家都採集過這些顏色,那每個月的色卡看起來會不會差不多?」
當然不會!因為生活的心情是有「份量」與「比重」的。
明天(Day 20),我們要引入「情緒佔比演算法」,讓出現頻率最高的心情主導整片光暈的範圍與色溫,並加上精準的百分比標籤,讓每個月的色卡真正成為獨一無二的樣子!