Created
January 31, 2026 07:56
-
-
Save joannewang0807-prog/70f67cf037b5548c740435fdfd8ccba4 to your computer and use it in GitHub Desktop.
2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>家族旅遊同步 2026</title> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { --lake-green: #78a5a3; --soft-bg: #f5f7f6; } | |
| body { background-color: var(--soft-bg); color: #4a4a4a; font-family: sans-serif; overflow-x: hidden; } | |
| .hide-scrollbar::-webkit-scrollbar { display: none; } | |
| .active-day { background-color: var(--lake-green) !important; color: white !important; transform: scale(1.05); } | |
| .sync-btn-active { animation: spin 1s linear infinite; } | |
| @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } | |
| .snap-x { scroll-snap-type: x mandatory; } | |
| .itinerary-card { scroll-snap-align: center; flex-shrink: 0; width: 85%; } | |
| .animate-slide-up { animation: slideUp 0.3s ease-out; } | |
| @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } | |
| </style> | |
| </head> | |
| <body class="max-w-md mx-auto min-h-screen relative flex flex-col shadow-inner"> | |
| <div id="app" class="flex-grow pb-32"> | |
| <div class="bg-teal-700 text-white text-[10px] py-2 px-4 flex justify-between items-center shadow-md"> | |
| <span><i class="fas fa-users mr-1"></i> 王敏苓家族同步中</span> | |
| <button @click="syncCloud" class="flex items-center bg-teal-600 px-2 py-0.5 rounded"> | |
| <i class="fas fa-sync-alt mr-1" :class="{'sync-btn-active': syncing}"></i> | |
| {{ syncing ? '同步中...' : '發布至雲端' }} | |
| </button> | |
| </div> | |
| <header class="p-6 pt-6 flex justify-between items-end"> | |
| <div> | |
| <h1 class="text-3xl font-light tracking-widest text-gray-700">{{ currentCity }}</h1> | |
| <p class="text-[10px] text-gray-400 mt-1 uppercase">2026/{{ currentRange }}</p> | |
| </div> | |
| <div class="text-right"> | |
| <span class="text-2xl font-light text-teal-600">{{ weather.temp }}°C</span> | |
| <p class="text-[10px] text-gray-400"><i :class="weather.icon" class="mr-1"></i>當地氣溫</p> | |
| </div> | |
| </header> | |
| <div class="px-4 mb-6"> | |
| <div class="flex overflow-x-auto hide-scrollbar space-x-3 py-2"> | |
| <button v-for="day in days" :key="day.id" @click="selectedDay = day.id" | |
| :class="selectedDay === day.id ? 'active-day' : 'bg-white text-gray-400'" | |
| class="flex-shrink-0 w-16 h-20 rounded-2xl shadow-sm flex flex-col items-center justify-center transition-all border border-gray-50"> | |
| <span class="text-[9px] font-bold uppercase">{{ day.city }}</span> | |
| <span class="text-sm font-bold my-0.5">{{ day.date }}</span> | |
| <span class="text-[10px] opacity-60">D{{ day.id }}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <main class="px-6"> | |
| <div v-if="activeTab === 'itinerary'"> | |
| <div class="bg-white/70 backdrop-blur-md rounded-2xl p-4 border border-white flex items-center justify-between shadow-sm mb-6"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-sm font-bold text-teal-600">{{ currentRateInfo.sign }}</span> | |
| <input v-model.number="inputAmount" type="number" class="w-20 bg-transparent border-b border-gray-200 text-lg font-mono focus:outline-none text-gray-700"> | |
| </div> | |
| <i class="fas fa-arrow-right text-gray-300 text-xs"></i> | |
| <div class="text-right"> | |
| <p class="text-[9px] text-gray-400 font-bold uppercase">台幣 NT$</p> | |
| <p class="text-lg font-bold text-teal-700">{{ twdResult }}</p> | |
| </div> | |
| </div> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-sm font-bold text-gray-500 border-l-4 border-teal-500 pl-3">每日行程</h2> | |
| <button @click="openAddModal" class="text-teal-600 text-xs font-bold">+ 新增行程</button> | |
| </div> | |
| <div class="flex overflow-x-auto snap-x hide-scrollbar gap-4 pb-4"> | |
| <div v-for="item in filteredItinerary" :key="item.id" class="itinerary-card bg-white rounded-3xl p-5 shadow-sm border border-gray-100 relative"> | |
| <div class="flex justify-between items-start mb-3"> | |
| <span class="text-xs font-mono text-teal-600 font-bold bg-teal-50 px-2 py-1 rounded">{{ item.time }}</span> | |
| <div class="flex gap-3 text-gray-300"> | |
| <button @click="openEditModal(item)" class="hover:text-teal-500"><i class="fas fa-edit"></i></button> | |
| <button @click="deleteItem(item.id)" class="hover:text-red-400"><i class="fas fa-trash"></i></button> | |
| </div> | |
| </div> | |
| <h3 class="font-bold text-gray-700 mb-2">{{ item.location }}</h3> | |
| <div v-if="item.image" class="w-full h-32 rounded-xl overflow-hidden mb-3"><img :src="item.image" class="w-full h-full object-cover"></div> | |
| <p class="text-[11px] text-gray-400 mb-4 line-clamp-2 italic">{{ item.note || '尚無備註' }}</p> | |
| <a :href="item.mapUrl || 'https://www.google.com/maps/search/' + encodeURIComponent(item.location)" target="_blank" class="block w-full text-center py-2.5 bg-gray-50 rounded-xl text-[10px] text-gray-500 font-bold"> | |
| <i class="fas fa-map-marker-alt mr-1"></i> 查看地圖 | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="activeTab === 'split'"> | |
| <div class="bg-white p-6 rounded-[30px] shadow-sm mb-6 border border-gray-100"> | |
| <h3 class="text-xs font-bold text-gray-400 mb-6 uppercase tracking-widest text-center">共享開銷 (D{{selectedDay}})</h3> | |
| <div class="space-y-4"> | |
| <input v-model="newExpense.item" placeholder="項目名稱" class="w-full border-b pb-2 text-sm focus:outline-none bg-transparent text-center"> | |
| <div class="flex gap-4"> | |
| <input v-model.number="newExpense.amount" type="number" placeholder="金額" class="w-1/2 border-b pb-2 text-sm focus:outline-none bg-transparent text-center"> | |
| <select v-model="newExpense.payer" class="w-1/2 border-b pb-2 text-sm focus:outline-none bg-transparent"> | |
| <option v-for="p in people" :value="p">{{ p }}</option> | |
| </select> | |
| </div> | |
| <button @click="addExpense" class="w-full bg-[#78a5a3] text-white py-3 rounded-xl text-sm font-bold shadow-lg">新增記錄</button> | |
| </div> | |
| </div> | |
| <div class="bg-teal-50/50 border border-teal-100 rounded-2xl p-4 mb-6 flex justify-between items-center"> | |
| <div> | |
| <p class="text-[10px] text-teal-600 font-bold uppercase tracking-wider">今日總支出</p> | |
| <p class="text-xl font-mono font-bold text-teal-800">{{ currentRateInfo.sign.split(' ')[1] }} {{ dailyTotal }}</p> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-[10px] text-gray-400 font-bold">約合台幣</p> | |
| <p class="text-lg font-bold text-gray-600">NT$ {{ Math.round(dailyTotal * currentRateInfo.rate) }}</p> | |
| </div> | |
| </div> | |
| <div class="space-y-3 pb-4"> | |
| <div v-for="(ex, i) in filteredExpenses" :key="i" class="bg-white/60 p-4 rounded-2xl flex justify-between items-center border border-white shadow-sm"> | |
| <div class="flex flex-col"> | |
| <span class="text-[10px] text-gray-400 font-bold">{{ ex.payer }}</span> | |
| <span class="text-sm font-bold text-gray-700">{{ ex.item }}</span> | |
| </div> | |
| <div class="flex items-center gap-3"> | |
| <span class="font-mono font-bold text-teal-600 text-sm">{{ ex.amount }}</span> | |
| <button @click="deleteExpense(i)" class="text-gray-200 hover:text-red-300 text-xs"><i class="fas fa-times"></i></button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <nav class="fixed bottom-6 left-1/2 -translate-x-1/2 w-[80%] bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex justify-around py-3 border border-white z-40"> | |
| <button @click="activeTab = 'itinerary'" :class="activeTab === 'itinerary' ? 'text-teal-600' : 'text-gray-300'"><i class="fas fa-calendar-alt text-xl"></i></button> | |
| <button @click="activeTab = 'split'" :class="activeTab === 'split' ? 'text-teal-600' : 'text-gray-300'"><i class="fas fa-wallet text-xl"></i></button> | |
| </nav> | |
| <div v-if="showEditModal" class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm"> | |
| <div class="bg-white w-full max-w-md rounded-t-[40px] p-8 animate-slide-up"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h3 class="font-bold text-gray-700">編輯行程</h3> | |
| <button @click="showEditModal = false" class="text-gray-300 text-2xl">×</button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div class="flex gap-4"> | |
| <div class="w-1/3"><label class="text-[10px] text-gray-400 font-bold">時間</label><input v-model="tempItem.time" type="time" class="w-full border-b py-2 focus:outline-none text-sm"></div> | |
| <div class="flex-1"><label class="text-[10px] text-gray-400 font-bold">標題</label><input v-model="tempItem.location" type="text" class="w-full border-b py-2 focus:outline-none text-sm"></div> | |
| </div> | |
| <div><label class="text-[10px] text-gray-400 font-bold">地圖連結</label><input v-model="tempItem.mapUrl" type="text" class="w-full border-b py-2 focus:outline-none text-sm"></div> | |
| <div><label class="text-[10px] text-gray-400 font-bold">備註</label><textarea v-model="tempItem.note" class="w-full border-b py-2 focus:outline-none text-sm h-16"></textarea></div> | |
| <button @click="saveItinerary" class="w-full bg-teal-600 text-white py-4 rounded-2xl font-bold">儲存</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, computed, onMounted, watch } = Vue; | |
| createApp({ | |
| setup() { | |
| const activeTab = ref('itinerary'); | |
| const selectedDay = ref(1); | |
| const syncing = ref(false); | |
| const weather = ref({ temp: '--', icon: 'fas fa-cloud-sun' }); | |
| const inputAmount = ref(100); | |
| const people = ['王敏苓', '爸爸', '媽媽']; | |
| const itinerary = ref([{ id: 1, day: 1, time: "14:00", location: "關西機場抵達", note: "拿取周遊券", mapUrl: "", image: null }]); | |
| const expenses = ref([]); | |
| const newExpense = ref({ item: '', amount: null, payer: '王敏苓' }); | |
| const showEditModal = ref(false); | |
| const isNewItem = ref(false); | |
| const tempItem = ref({}); | |
| const days = []; | |
| const cities = ['大阪','大阪','大阪','京都','京都','京都','神戶','神戶','神戶','神戶','曼谷','曼谷','曼谷','曼谷','清邁','清邁','清邁','清邁','清邁','檳城','檳城','檳城','檳城','檳城','檳城']; | |
| const cityCoords = { '大阪': { lat: 34.69, lng: 135.50 }, '京都': { lat: 35.01, lng: 135.76 }, '神戶': { lat: 34.69, lng: 135.19 }, '曼谷': { lat: 13.75, lng: 100.50 }, '清邁': { lat: 18.78, lng: 98.98 }, '檳城': { lat: 5.41, lng: 100.32 } }; | |
| for(let i=1; i<=25; i++){ | |
| const d = new Date(2026, 1, 9); | |
| d.setDate(d.getDate() + (i-1)); | |
| days.push({ id: i, date: `${d.getMonth()+1}/${d.getDate()}`, city: cities[i-1] }); | |
| } | |
| const currentCity = computed(() => days[selectedDay.value-1].city); | |
| const currentRange = computed(() => days[selectedDay.value-1].date); | |
| const filteredItinerary = computed(() => itinerary.value.filter(i => i.day === selectedDay.value).sort((a,b) => a.time.localeCompare(b.time))); | |
| const filteredExpenses = computed(() => expenses.value.filter(ex => ex.day === selectedDay.value)); | |
| // 當日總計邏輯 (新增) | |
| const dailyTotal = computed(() => { | |
| return filteredExpenses.value.reduce((sum, ex) => sum + (Number(ex.amount) || 0), 0); | |
| }); | |
| const currentRateInfo = computed(() => { | |
| const rates = { '大阪': { sign: 'JPY ¥', rate: 0.21 }, '京都': { sign: 'JPY ¥', rate: 0.21 }, '神戶': { sign: 'JPY ¥', rate: 0.21 }, '曼谷': { sign: 'THB ฿', rate: 0.92 }, '清邁': { sign: 'THB ฿', rate: 0.92 }, '檳城': { sign: 'MYR RM', rate: 7.1 } }; | |
| return rates[currentCity.value] || { sign: 'JPY ¥', rate: 0.21 }; | |
| }); | |
| const twdResult = computed(() => Math.round(inputAmount.value * currentRateInfo.value.rate)); | |
| const fetchWeather = async () => { | |
| try { | |
| const coords = cityCoords[currentCity.value]; | |
| const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}&longitude=${coords.lng}¤t_weather=true`); | |
| const data = await res.json(); | |
| weather.value.temp = Math.round(data.current_weather.temperature); | |
| weather.value.icon = data.current_weather.weathercode < 3 ? 'fas fa-sun' : 'fas fa-cloud'; | |
| } catch (e) { weather.value.temp = '--'; } | |
| }; | |
| const openAddModal = () => { isNewItem.value = true; tempItem.value = { id: Date.now(), day: selectedDay.value, time: '10:00', location: '', mapUrl: '', note: '' }; showEditModal.value = true; }; | |
| const openEditModal = (item) => { isNewItem.value = false; tempItem.value = { ...item }; showEditModal.value = true; }; | |
| const saveItinerary = () => { if(isNewItem.value) itinerary.value.push(tempItem.value); else { const idx = itinerary.value.findIndex(i => i.id === tempItem.value.id); itinerary.value[idx] = tempItem.value; } showEditModal.value = false; }; | |
| const deleteItem = (id) => { if(confirm('刪除?')) itinerary.value = itinerary.value.filter(i => i.id !== id); }; | |
| const addExpense = () => { if(!newExpense.value.amount) return; expenses.value.push({ ...newExpense.value, day: selectedDay.value }); newExpense.value = { item: '', amount: null, payer: '王敏苓' }; }; | |
| const deleteExpense = (index) => { expenses.value.splice(index, 1); }; | |
| const syncCloud = () => { syncing.value = true; setTimeout(() => syncing.value = false, 1500); }; | |
| onMounted(fetchWeather); | |
| watch(selectedDay, fetchWeather); | |
| return { | |
| activeTab, selectedDay, days, itinerary, filteredItinerary, expenses, filteredExpenses, newExpense, dailyTotal, | |
| currentCity, currentRange, weather, syncing, syncCloud, inputAmount, currentRateInfo, twdResult, | |
| showEditModal, isNewItem, tempItem, openAddModal, openEditModal, saveItinerary, deleteItem, | |
| people, addExpense, deleteExpense | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment