iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Modern Web

微光採集瓶:用 Vue 3 打造每日美好記錄網頁系列 第 19 篇

【Day 19】封存月份的美好:月度色卡的誕生及儀式感動畫

  • 分享至 

  • xImage
  •  

隨著日子一天天過去,瓶底積累了每天最幸福的痕跡,以及忙著生活的象徵。如果只是任由它們堆在瓶底,是不是少了一點儀式感。

今天我們要實作一個重磅功能:在月底最後一天放入最後一顆微光時,軟木塞將被打開,所有光珠像是流星一顆顆飛出瓶口,在半空中聚攏凝結成一張專屬於該月份的「微光總結色卡」!

一、互動體驗與邊界設計

為了讓月度回顧這個功能不只充滿儀式感,還要符合邏輯,我們梳理出四個細節:

  1. 採集防呆:今天若已採集過微光,輸入框與按鈕將自動轉為鎖定狀態(「今天的美好已安放,明天見~」),避免重複記錄。
  2. 月底儀式感觸發(時機 A):在每個月的最後一天按下採集時,觸發「封存儀式」。
  • Step 1:軟木塞離開瓶口。
  • Step 2:瓶內光珠由最上層開始,一顆接一顆飛出瓶口。
  • Step 3:光芒聚攏,總結色卡浮現在畫面上。
  1. 隔月 1 號補領提醒(時機 B):如果月底那天沒開網頁,隔月 1 號打開時,頂部會出現補領橫幅,依然能點擊回顧上月色卡。
  2. 色卡視覺設計(拍立得風格):上半部為畫布(利用 CSS 多重 radial-gradient 與高斯模糊暈染當月色彩);下半部為留白區,包含微光總數與色票圓點。

二、核心程式碼實作

今天的程式碼有很多動畫的呈現,所以篇幅比較長,但程式碼都有加上註解所以不用擔心會很複雜。

1. <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'
  }
})

2. <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>

3. <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;
}

三、成果驗證

  1. 今日已記錄防呆:放入一顆光珠後,下方輸入框隨即鎖定,按鈕轉灰並顯示「今日微光已入瓶」,確實落實了一天一小事的初心。
  2. 點擊「查看本月微光色卡」手動預覽:
  • 瓶塞彈開:軟木塞先向外翻轉彈開,預留短暫延遲。
  • 光珠依序飛出:瓶內光珠由上層至下層一顆顆直線衝出瓶口。
  • 色卡展開:動畫結束後,畫面中央彈出整合了當月心情色彩的總結色卡。

這些內容我們等明天再測試,測試完剛好可以直接模擬不同月份的假資料,來驗證另一個重要細節:「情緒色彩佔比」。

明日預告

看著眼前的月度色卡,細心的你可能已經發現了一個問題:
「如果我們的心情顏色固定就是那幾種,每個月大家都採集過這些顏色,那每個月的色卡看起來會不會差不多?」
當然不會!因為生活的心情是有「份量」與「比重」的。
明天(Day 20),我們要引入「情緒佔比演算法」,讓出現頻率最高的心情主導整片光暈的範圍與色溫,並加上精準的百分比標籤,讓每個月的色卡真正成為獨一無二的樣子!


上一篇
【Day 18】時光篩選器:快速找到特定日子的回憶
系列文
微光採集瓶:用 Vue 3 打造每日美好記錄網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言