Created
January 31, 2026 05:37
-
-
Save joannewang0807-prog/6e037c0d5cbf9c0d715db1b383955da0 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-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 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/' + 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 text-center"> | |
| <h3 class="text-xs font-bold text-gray-400 mb-6 uppercase tracking-widest">共享開銷記錄</h3> | |
| <div class="space-y-4 text-left"> | |
| <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([ | |
| { day: 1, time: "14:00", location: "抵達關西機場 / 搭乘利木津巴士" }, | |
| { day: 1, time: "16:00", location: "入住大阪飯店 (心齋橋附近)" }, | |
| { day: 1, time: "18:30", location: "道頓堀晚餐、逛街" }, | |
| { day: 2, time: "09:30", location: "大阪城天守閣" }, | |
| { day: 2, time: "13:00", location: "黑門市場午餐" }, | |
| { day: 2, time: "15:30", location: "通天閣 / 新世界逛街" }, | |
| { day: 4, time: "10:00", location: "移動至京都 / 金閣寺" }, | |
| { day: 4, time: "14:00", location: "清水寺、二年坂、三年坂" }, | |
| { day: 7, time: "10:00", location: "移動至神戶 / 北野異人館" }, | |
| { day: 7, time: "18:00", location: "神戶港夜景" }, | |
| { day: 11, time: "15:00", location: "抵達曼谷機場 / 入住飯店" }, | |
| { day: 11, time: "19:00", location: "喬德夜市 (Jodd Fairs)" }, | |
| { day: 15, time: "10:00", location: "清邁古城區巡禮" }, | |
| { day: 15, time: "18: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 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 }) | |
| }); | |
| alert('已成功同步至雲端!家人現在可以看到最新的 25 天行程了。'); | |
| } catch (e) { alert('同步失敗'); } | |
| 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: '新行程' }); | |
| }; | |
| const deleteItem = (idx) => { | |
| itinerary.value.splice(idx, 1); | |
| }; | |
| 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(() => { 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