Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save joannewang0807-prog/7a42e7df5810f7bdd1de306793234b31 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; }
.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}&current_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