Created
January 31, 2026 04:32
-
-
Save joannewang0807-prog/c3727f6a87bdc185691148b45ce5f081 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); } } | |
| </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-1.5 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 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" @change="debouncedSync" 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" @change="debouncedSync" placeholder="點擊編輯行程..." class="w-full font-medium text-gray-700 focus:outline-none mb-2 bg-transparent"> | |
| <div class="flex justify-between"> | |
| <a :href="'https://www.google.com/maps/search/' + encodeURIComponent(item.location + ' ' + currentCity)" target="_blank" class="text-[10px] text-gray-400"> | |
| <i class="fas fa-location-arrow 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">共享開銷記錄</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"> | |
| <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"> | |
| <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="space-y-2"> | |
| <div v-for="(ex, i) in expenses" :key="i" class="bg-white/60 p-3 rounded-xl flex justify-between text-xs border border-white"> | |
| <span><b class="text-teal-600">{{ ex.payer }}</b> 支付了 {{ ex.item }}</span> | |
| <span class="font-bold text-gray-600">¥{{ ex.amount }}</span> | |
| </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 itinerary = ref([]); | |
| const expenses = ref([]); | |
| const newExpense = ref({ item: '', amount: null, payer: '我' }); | |
| // 生成 25 天日期 | |
| 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 syncCloud = async () => { | |
| syncing.value = true; | |
| try { | |
| // 1. 先把本地當前的資料傳上去 (PUT) | |
| const saveRes = 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 }) | |
| }); | |
| // 2. 再抓取最新的下來確保同步 (GET) | |
| const loadRes = await fetch(`https://api.jsonbin.io/v3/b/${BIN_ID}/latest`, { | |
| headers: { 'X-Master-Key': API_KEY } | |
| }); | |
| const data = await loadRes.json(); | |
| itinerary.value = data.record.itinerary || []; | |
| expenses.value = data.record.expenses || []; | |
| } catch (e) { | |
| console.error('同步失敗', e); | |
| } | |
| syncing.value = false; | |
| }; | |
| const addExpense = () => { | |
| if(!newExpense.value.amount || !newExpense.value.item) return; | |
| expenses.value.push({...newExpense.value}); | |
| newExpense.value = { item: '', amount: null, payer: '我' }; | |
| syncCloud(); // 自動同步 | |
| }; | |
| const addItem = () => { | |
| itinerary.value.push({ day: selectedDay.value, time: '12:00', location: '新行程' }); | |
| syncCloud(); | |
| }; | |
| const deleteItem = (idx) => { | |
| itinerary.value.splice(idx, 1); | |
| syncCloud(); | |
| }; | |
| const currentCity = computed(() => days[selectedDay.value-1].city); | |
| const currentRange = computed(() => days[selectedDay.value-1].date); | |
| 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(() => { | |
| syncCloud(); | |
| fetchWeather(); | |
| }); | |
| watch(selectedDay, fetchWeather); | |
| return { | |
| activeTab, selectedDay, days, itinerary, expenses, newExpense, | |
| currentCity, currentRange, addItem, deleteItem, addExpense, weather, syncing, syncCloud, people | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment