Created
January 31, 2026 07:33
-
-
Save joannewang0807-prog/7a42e7df5810f7bdd1de306793234b31 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; } | |
| .hide-scrollbar::-webkit-scrollbar { display: none; } | |
| .active-day { background-color: #78a5a3 !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); } } | |
| input[type="number"]::-webkit-inner-spin-button, | |
| input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 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-28"> | |
| <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">當地氣溫</p> | |
| </div> | |
| </header> | |
| <div v-if="activeTab === 'itinerary'" class="px-6 mb-4"> | |
| <div class="bg-white/70 backdrop-blur-md rounded-2xl p-4 border border-white flex items-center justify-between shadow-sm"> | |
| <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> | |
| <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="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="addItem" class="text-teal-600 text-xs font-bold">+ 新增行程</button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div v-for="(item, index) in itinerary" :key="index"> | |
| <div v-if="item.day === selectedDay" class="flex gap-4"> | |
| <input v-model="item.time" class="text-[10px] font-mono text-teal-600 w-12 text-center focus:outline-none bg-transparent"> | |
| <div class="bg-white p-4 rounded-3xl shadow-sm flex-grow border border-gray-100"> | |
| <input v-model="item.location" class="w-full font-medium text-gray-700 focus:outline-none mb-1 bg-transparent"> | |
| <div class="flex justify-between"> | |
| <a :href="'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(item.location)" target="_blank" class="text-[10px] text-gray-400"> | |
| <i class="fas fa-map-marker-alt mr-1"></i> 地圖 | |
| </a> | |
| <button @click="deleteItem(index)" class="text-red-200 text-[10px]">刪除</button> | |
| </div> | |
| </div> | |
| </div> | |
| </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="mb-6"> | |
| <div class="bg-teal-50 border border-teal-100 rounded-2xl p-4 flex justify-between items-center shadow-sm"> | |
| <div> | |
| <p class="text-[10px] text-teal-600 font-bold uppercase tracking-wider">Day {{ selectedDay }} 總支出</p> | |
| <p class="text-[10px] text-gray-400">約 NT$ {{ (dailyTotal * currentRateInfo.rate).toLocaleString(undefined, {maximumFractionDigits: 0}) }}</p> | |
| </div> | |
| <div class="text-right"> | |
| <span class="text-2xl font-mono font-black text-teal-700"> | |
| {{ currentRateInfo.sign.split(' ')[1] }} {{ dailyTotal.toLocaleString() }} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="space-y-3"> | |
| <div v-for="(ex, i) in expenses" :key="i"> | |
| <div v-if="ex.day === selectedDay" class="bg-white/60 p-4 rounded-2xl flex flex-col gap-2 border border-white shadow-sm"> | |
| <div v-if="editingIdx !== i" class="flex justify-between items-center"> | |
| <div class="flex flex-col"> | |
| <span class="text-[10px] text-gray-400">{{ ex.payer }} 支付</span> | |
| <span class="text-sm font-bold text-gray-700">{{ ex.item }}</span> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <span class="font-mono font-bold text-teal-600">{{ currentRateInfo.sign.split(' ')[1] }} {{ ex.amount.toLocaleString() }}</span> | |
| <div class="flex gap-2 text-gray-300"> | |
| <button @click="startEdit(i)" class="hover:text-teal-500"><i class="fas fa-edit text-xs"></i></button> | |
| <button @click="deleteExpense(i)" class="hover:text-red-400"><i class="fas fa-trash text-xs"></i></button> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-else class="flex flex-col gap-2"> | |
| <input v-model="ex.item" class="text-sm border-b focus:outline-none bg-transparent font-bold"> | |
| <div class="flex justify-between items-center"> | |
| <input v-model.number="ex.amount" type="number" class="w-20 text-sm border-b focus:outline-none bg-transparent font-mono"> | |
| <div class="flex gap-2"> | |
| <button @click="editingIdx = null; syncCloud()" class="bg-teal-500 text-white px-3 py-1 rounded-lg text-[10px]">儲存</button> | |
| <button @click="editingIdx = null" class="bg-gray-200 text-gray-500 px-3 py-1 rounded-lg text-[10px]">取消</button> | |
| </div> | |
| </div> | |
| </div> | |
| </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"> | |
| <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> | |
| <script> | |
| const { createApp, ref, computed, onMounted, watch } = Vue; | |
| const BIN_ID = '697d5c3bae596e708f050afc'; | |
| const API_KEY = '$2a$10$2Kgm6wzaFeScgJsNqCvl1.r2p9oWzCjF3hoR8KqsY0IPDfTAJgeSa'; | |
| createApp({ | |
| setup() { | |
| const activeTab = ref('itinerary'); | |
| const selectedDay = ref(1); | |
| const syncing = ref(false); | |
| const weather = ref({ temp: '--' }); | |
| const people = ['王敏苓', '爸爸', '媽媽']; | |
| const inputAmount = ref(100); | |
| const editingIdx = ref(null); | |
| const exchangeRates = { | |
| '大阪': { 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.10 } | |
| }; | |
| const itinerary = ref([ | |
| { day: 1, time: "14:00", location: "抵達關西機場" }, | |
| { day: 11, time: "15:00", location: "抵達曼谷機場" } | |
| ]); | |
| const expenses = ref([]); | |
| const newExpense = ref({ item: '', amount: null, payer: '王敏苓' }); | |
| const days = []; | |
| const startDate = new Date(2026, 1, 9); | |
| const cities = ['大阪','大阪','大阪','京都','京都','京都','神戶','神戶','神戶','神戶','曼谷','曼谷','曼谷','曼谷','清邁','清邁','清邁','清邁','清邁','檳城','檳城','檳城','檳城','檳城','檳城']; | |
| for(let i=1; i<=25; i++) { | |
| const cur = new Date(startDate); | |
| cur.setDate(startDate.getDate() + (i-1)); | |
| days.push({ id: i, date: `${cur.getMonth()+1}/${cur.getDate()}`, city: cities[i-1], lat: cities[i-1] === '曼谷' ? 13.7 : 34.6, lng: cities[i-1] === '曼谷' ? 100.5 : 135.5 }); | |
| } | |
| const currentCity = computed(() => days[selectedDay.value-1].city); | |
| const currentRange = computed(() => days[selectedDay.value-1].date); | |
| const currentRateInfo = computed(() => exchangeRates[currentCity.value] || { sign: 'JPY ¥', rate: 0.21 }); | |
| const twdResult = computed(() => (inputAmount.value * currentRateInfo.value.rate).toLocaleString(undefined, {maximumFractionDigits: 0})); | |
| // 今日花費總計 (只計算當前 selectedDay) | |
| const dailyTotal = computed(() => { | |
| return expenses.value | |
| .filter(ex => ex.day === selectedDay.value) | |
| .reduce((sum, ex) => sum + (Number(ex.amount) || 0), 0); | |
| }); | |
| const syncCloud = async () => { | |
| syncing.value = true; | |
| try { | |
| await fetch(`https://api.jsonbin.io/v3/b/${BIN_ID}`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json', 'X-Master-Key': API_KEY }, | |
| body: JSON.stringify({ itinerary: itinerary.value, expenses: expenses.value }) | |
| }); | |
| } catch (e) { console.error('同步失敗'); } | |
| syncing.value = false; | |
| }; | |
| const addExpense = () => { | |
| if(!newExpense.value.amount || !newExpense.value.item) return; | |
| expenses.value.unshift({ | |
| ...newExpense.value, | |
| day: selectedDay.value // 關鍵:記錄這筆帳屬於哪一天 | |
| }); | |
| newExpense.value = { item: '', amount: null, payer: '王敏苓' }; | |
| syncCloud(); | |
| }; | |
| const startEdit = (idx) => { editingIdx.value = idx; }; | |
| const deleteExpense = (idx) => { | |
| if(confirm('確定要刪除這筆開銷嗎?')) { | |
| expenses.value.splice(idx, 1); | |
| syncCloud(); | |
| } | |
| }; | |
| const addItem = () => { itinerary.value.push({ day: selectedDay.value, time: '12:00', location: '新行程' }); }; | |
| const deleteItem = (idx) => { itinerary.value.splice(idx, 1); }; | |
| const fetchWeather = async () => { | |
| try { | |
| const d = days[selectedDay.value-1]; | |
| const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${d.lat}&longitude=${d.lng}¤t_weather=true`); | |
| const data = await res.json(); | |
| weather.value.temp = Math.round(data.current_weather.temperature); | |
| } catch (e) { weather.value.temp = '--'; } | |
| }; | |
| onMounted(fetchWeather); | |
| watch(selectedDay, fetchWeather); | |
| return { | |
| activeTab, selectedDay, days, itinerary, expenses, newExpense, | |
| currentCity, currentRange, addItem, deleteItem, addExpense, weather, syncing, syncCloud, people, | |
| inputAmount, currentRateInfo, twdResult, editingIdx, startEdit, deleteExpense, dailyTotal | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment