iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Modern Web

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

【Day 9】寫下今天的心情!v-model 讓輸入框記住你想說的話

  • 分享至 

  • xImage
  •  

昨天我們替採集瓶加上了心情調色盤,讓每次採集前都能選取屬於今天的色彩。但光有顏色還不夠,如果不能把當天發生的那件小事化作文字,時間一久,可能就忘了當初為什麼選了那個顏色。
所以今天我們要替採集瓶加上最重要的「文字留言框」,並認識 Vue 在表單處理上的重點工具:雙向綁定 v-model。

一、什麼是 v-model?認識「雙向綁定」

在認識 v-model 之前,我們先回顧一下前面幾天學過的語法:

  • 雙大括號 {{ count }}:把 JS 的資料,單純印到 HTML 畫面上。
  • 屬性綁定 :class / :style:由 JS 的狀態,決定 HTML 標籤的樣式。

你有發現共通點嗎?它們都是單向的(JavaScript ➜ 畫面):只要 JS 裡的資料變了,畫面就會跟著更新。

但輸入框(<input>)不一樣。輸入框是讓「使用者」在畫面上打字的地方。我們不僅希望 JS 能把文字塞進輸入框,更需要使用者在鍵盤敲下文字時,JS 裡的變數能「即時」拿到最新打進去的字。
這種「資料變,畫面跟著變;畫面被使用者修改,資料也即時同步更新」的雙向溝通,就叫做雙向綁定(Two-Way Binding),而 Vue 在這方面給我們的專屬指令就是 v-model 。

v-model 是一種語法糖?

v-model 其實不是一個新的語法,而是把我們在 Day 6 學的 v-bind(:) 和 Day 8 學到的 v-on (@)結合在一起,Vue 利用 v-model 來同時做這兩個指令。
範例:

<!-- 原始寫法 -->
<input 
  :value="todayNote" 
  @input="todayNote = $event.target.value" 
/>

<!-- 透過 v-model 的寫法 -->
<input v-model="todayNote" />
  • :value="todayNote":負責把變數的值送到輸入框顯示(v-bind 的工作)。
  • @input="...":監聽使用者的打字輸入事件,把最新輸入的字存回變數(v-on 的工作)。

Vue 知道每次寫表單都要拆成這兩行太麻煩了,所以發明了 v-model,讓我們用一個指令同時搞定輸入與同步。

二、動手做:在 GemJar.vue 加上文字輸入框

打開 src/components/GemJar.vue,依序進行修改。

1. <script setup>:新增文字變數與採集檢查

在原本的 selectedColor 下方,新增變數來存使用者的文字:

// 存放輸入框的文字
const todayNote = ref('')

接著找到原本的 addGem() 函式,在最前面加入「沒打字就跳提示」的檢查,並在採集成功後印出結果並清空:

function addGem() {
  // 新增:如果沒打字,就不給放入
  if (todayNote.value.trim() === '') {
    alert('請先寫下一件今天的美好小事喔!')
    return
  }

  count.value++
  isGlowing.value = true

  // 新增:在 Console 確認採集到的完整資訊
  console.log('採集成功!', {
    color: selectedColor.value,
    note: todayNote.value
  })

  // 新增:採集後清空輸入框
  todayNote.value = ''

  setTimeout(() => {
    isGlowing.value = false
  }, 1000)
}

2. <template>:在調色盤下方插入輸入框

找到調色盤 </div> 與「放入這道微光」<button> 之間,把這段輸入框群組放進去:

</div> <!-- 原本調色盤的結尾 -->

  <!-- 新增:文字輸入區域 -->
  <div class="input-group">
    <input 
      v-model="todayNote" 
      type="text" 
      maxlength="20" 
      placeholder="寫下一件微小的好事..." 
      class="note-input"
    />
    <!-- 即時計算剩餘字數 -->
    <span class="char-count">{{ 20 - todayNote.length }} / 20</span>
  </div>

  <button class="btn-collect" ...> <!-- 這是原本的按鈕 -->

因為我希望使用者每天僅需花少少的時間簡單記下今天的美好,所以字數限制為20字。

3. <style scoped>:輸入框排版

原本的瓶子、調色盤樣式完全不用動,直接滑到 CSS 最底部,貼上輸入框的外觀設定:

/* 新增:輸入框外層容器 */
.input-group {
  display: flex;
  align-items: center;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 8px;
  padding: 6px 12px;
  margin-bottom: 16px;
  width: 240px;
  box-sizing: border-box;
}

/* 新增:輸入框本體 */
.note-input {
  background: transparent;
  border: none;
  outline: none;
  color: #ffffff;
  font-size: 0.85rem;
  width: 100%;
}

.note-input::placeholder {
  color: #6e7681;
}

/* 新增:字數提示文字 */
.char-count {
  font-size: 0.75rem;
  color: #8b949e;
  white-space: nowrap;
  margin-left: 8px;
}

以上分段敘述新增的功能和程式碼,以下是完整 GemJar.vue 程式碼:

<script setup>
import { ref } from 'vue'

const count = ref(0)
const isGlowing = ref(false)
const selectedColor = ref('#E3B341')

// 1. 存放輸入框文字
const todayNote = ref('')

// 2. 切換顏色的函式,接收使用者點選的顏色
function selectColor(color) {
  selectedColor.value = color
}

function addGem() {
  // 防呆機制:若未輸入文字則中斷
  if (todayNote.value.trim() === '') {
    alert('請先寫下一件今天的美好小事喔!')
    return
  }

  count.value++
  isGlowing.value = true

  // 在 Console 印出採集到的顏色與文字
  console.log('採集成功!', {
    color: selectedColor.value,
    note: todayNote.value
  })

  // 採集完成後清空輸入框
  todayNote.value = ''

  setTimeout(() => {
    isGlowing.value = false
  }, 1000)
}
</script>

<template>
  <div class="bottle-wrapper">
    <div class="cork"></div>
    <div class="bottle" :class="{ 'is-glowing': isGlowing }">
      <span v-if="count === 0">瓶子目前是空的</span>
    </div>
  </div>

  <p>已採集 {{ count }} 顆光珠</p>

  <!-- 心情調色盤 -->
  <div class="palette">
    <button 
      class="color-dot blue" 
      :class="{ 'is-active': selectedColor === '#64B5F6' }"
      @click="selectColor('#64B5F6')"
    ></button>
    <button 
      class="color-dot gold" 
      :class="{ 'is-active': selectedColor === '#E3B341' }"
      @click="selectColor('#E3B341')"
    ></button>
    <button 
      class="color-dot pink" 
      :class="{ 'is-active': selectedColor === '#F48FB1' }"
      @click="selectColor('#F48FB1')"
    ></button>
    <button 
      class="color-dot green" 
      :class="{ 'is-active': selectedColor === '#81C784' }"
      @click="selectColor('#81C784')"
    ></button>
  </div>

  <!-- 文字輸入區域(雙向綁定與即時剩餘字數) -->
  <div class="input-group">
    <input 
      v-model="todayNote" 
      type="text" 
      maxlength="20" 
      placeholder="寫下一件微小的好事..." 
      class="note-input"
    />
    <span class="char-count">{{ 20 - todayNote.length }} / 20</span>
  </div>

  <!-- 採集按鈕 -->
  <button 
    class="btn-collect" 
    :style="{ backgroundColor: selectedColor }"
    @click="addGem"
  >
    放入這道微光
  </button>
</template>

<style scoped>
/* 瓶子的樣式 */
.bottle-wrapper {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 16px 0;
}

/* 軟木塞 */
.cork {
  width: 40px;
  height: 12px;
  background-color: #a07855;
  border-radius: 3px;
}

/* 瓶身 */
.bottle {
  width: 160px;
  height: 220px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  background: rgba(255, 255, 255, 0.04);
  border-radius: 12px 12px 40px 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #6e7681;
  font-size: 0.85rem;
  transition: border-color 1.0s ease, box-shadow 1.0s ease;
}

.bottle.is-glowing {
  border-color: rgba(227, 179, 65, 0.95);
  box-shadow: 0 0 24px rgba(227, 179, 65, 0.5);
}

/* 調色盤排版 */
.palette {
  display: flex;
  gap: 12px;
  margin: 12px 0 16px 0;
}

/* 心情代表色 */
.color-dot {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 2px solid transparent;
  cursor: pointer;
  transition: transform 0.2s ease, border-color 0.2s ease;
}

/* 各別顏色定義 */
.color-dot.blue { background-color: #64B5F6; }
.color-dot.gold { background-color: #E3B341; }
.color-dot.pink { background-color: #F48FB1; }
.color-dot.green { background-color: #81C784; }

/* 當前被選中的心情代表色 */
.color-dot.is-active {
  border-color: #ffffff;
  transform: scale(1.25);
}

/* 輸入框外層容器 */
.input-group {
  display: flex;
  align-items: center;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 8px;
  padding: 6px 12px;
  margin-bottom: 16px;
  width: 240px;
  box-sizing: border-box;
}

/* 輸入框文字本體 */
.note-input {
  background: transparent;
  border: none;
  outline: none;
  color: #ffffff;
  font-size: 0.85rem;
  width: 100%;
}

.note-input::placeholder {
  color: #6e7681;
}

/* 即時字數計數標籤 */
.char-count {
  font-size: 0.75rem;
  color: #8b949e;
  white-space: nowrap;
  margin-left: 8px;
}

/* 採集按鈕樣式 */
.btn-collect {
  padding: 8px 20px;
  border: none;
  border-radius: 20px;
  color: #1a1a1a;
  font-weight: bold;
  cursor: pointer;
  transition: background-color 0.3s ease, transform 0.1s ease;
}

.btn-collect:active {
  transform: scale(0.96);
}
</style>

三、成果驗證

存檔後回到瀏覽器測試:

  1. 即時打字連動:在輸入框隨意敲幾個字,右側是不是即時計算剩餘字數?
    https://ithelp.ithome.com.tw/upload/images/20260923/20178067zPA6BrBq7v.png

  2. 防呆檢查:如果輸入框是空的,直接按「放入這道微光」,會跳出提醒視窗請你輸入內容。
    https://ithelp.ithome.com.tw/upload/images/20260923/20178067EPAdljdnzd.png

  3. 資料打包確認:選好顏色、輸入「今天買了喜歡的積木」,按下按鈕。打開瀏覽器的 Console,會看到成功印出了帶有顏色與文字的完整紀錄,且按鈕按下的瞬間,輸入框也自動被清空了。
    https://ithelp.ithome.com.tw/upload/images/20260923/20178067WUBFZDXf9m.png

今日小結

  1. 單向 vs 雙向:雙大括號與 v-bind 是單向傳遞(資料 ➜ 畫面);而 v-model 是雙向同步,專門用在表單輸入元素上。
  2. 語法糖本質:v-model 等同於 :value(動態綁定值)加上 @input(監聽打字事件)的合體。
  3. 採集三要素:到今天為止,我們有了「數量(count)」、「顏色(selectedColor)」以及「故事(todayNote)」。

明日預告

現在按下按鈕時,Console 已經能印出一筆完整的紀錄了,但我們不能讓紀錄只留在 Console 裡。明天(Day 10),我們將嘗試用「陣列物件」把這些資料封裝成一顆顆真正的光珠資料,讓光珠在瓶子裡可視化!


上一篇
【Day 8】 為今日心情選一個代表色!事件處理 v-on 與 @click
下一篇
【Day 10】列表渲染:v-for 與光珠可視化
系列文
微光採集瓶:用 Vue 3 打造每日美好記錄網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言