Skip to content

Instantly share code, notes, and snippets.

@joannewang0807-prog
Created January 31, 2026 07:56
Show Gist options
  • Select an option

  • Save joannewang0807-prog/70f67cf037b5548c740435fdfd8ccba4 to your computer and use it in GitHub Desktop.

Select an option

Save joannewang0807-prog/70f67cf037b5548c740435fdfd8ccba4 to your computer and use it in GitHub Desktop.
2026
<!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}&current_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