feat: 오늘 날씨 위젯 표시 및 주간 날씨 예보 팝업 다이얼로그 추가
Build and Deploy / deploy (push) Failing after 14s

This commit is contained in:
simva committed 2026-10-23 13:39:28 +09:00
1 parent 42008079db
commit c86ec6a84f
10 files changed
+660 -47

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
const BASE_URL = '/api';
export async function fetchWeather(lat = 37.5665, lon = 126.9780) {
const query = new URLSearchParams({ lat, lon }).toString();
const res = await fetch(`${BASE_URL}/weather?${query}`);
if (!res.ok) {
throw new Error('날씨 정보를 불러오지 못했습니다.');
}
const data = await res.json();
if (!data.success) {
throw new Error(data.message || '날씨 정보 조회 실패');
}
return data.data;
}
+100
View File
@@ -1,5 +1,8 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useTheme } from 'vuetify';
import { fetchWeather } from '../api/weather';
import WeatherDialog from './WeatherDialog.vue';
const props = defineProps({
status: {
@@ -20,6 +23,58 @@ const theme = useTheme();
const toggleTheme = () => {
theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark';
};
// 날씨 관련 상태
const weather = ref(null);
const weatherLoading = ref(false);
const showWeatherDialog = ref(false);
const currentCoords = ref({ lat: 37.5665, lon: 126.9780 });
let weatherTimer = null;
const loadSavedCoords = () => {
const saved = localStorage.getItem('simva_weather_coords');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) {
currentCoords.value = parsed;
}
} catch (e) {
console.warn('저장된 날씨 위치 파싱 실패:', e);
}
}
};
const loadWeather = async () => {
weatherLoading.value = true;
try {
const data = await fetchWeather(currentCoords.value.lat, currentCoords.value.lon);
weather.value = data;
} catch (err) {
console.error('날씨 조회 오류:', err);
} finally {
weatherLoading.value = false;
}
};
const handleLocationChange = (coords) => {
currentCoords.value = coords;
localStorage.setItem('simva_weather_coords', JSON.stringify(coords));
loadWeather();
};
onMounted(() => {
loadSavedCoords();
loadWeather();
// 15분마다 자동 갱신
weatherTimer = setInterval(loadWeather, 15 * 60 * 1000);
});
onUnmounted(() => {
if (weatherTimer) {
clearInterval(weatherTimer);
}
});
</script>
<template>
@@ -48,6 +103,32 @@ const toggleTheme = () => {
<v-spacer></v-spacer>
<!-- 오늘 날씨 칩 (클릭 시 주간 예보 다이얼로그 오픈) -->
<v-chip
v-if="weather"
variant="tonal"
size="small"
class="mr-2 font-weight-medium weather-chip"
@click="showWeatherDialog = true"
title="클릭하여 주간 날씨 예보 보기"
>
<v-icon start :color="weather.current.color" size="18">{{ weather.current.icon }}</v-icon>
<span class="font-weight-bold mr-1">{{ weather.current.temperature }}°C</span>
<span class="d-none d-sm-inline text-medium-emphasis">{{ weather.current.weather_text }}</span>
<span class="d-none d-md-inline ml-1 text-disabled">· {{ weather.location.name }}</span>
</v-chip>
<!-- 날씨 로딩 상태 표시 -->
<v-chip
v-else-if="weatherLoading"
variant="tonal"
size="small"
class="mr-2"
>
<v-progress-circular indeterminate size="12" width="2" class="mr-1"></v-progress-circular>
<span class="text-caption">날씨 조회 중...</span>
</v-chip>
<!-- 연결 상태 칩 -->
<v-chip
:color="status.connected ? 'success' : 'error'"
@@ -83,5 +164,24 @@ const toggleTheme = () => {
@click="$emit('refresh')"
title="새로고침"
></v-btn>
<!-- 주간 날씨 다이얼로그 -->
<WeatherDialog
v-model="showWeatherDialog"
:weather="weather"
:loading="weatherLoading"
@refresh="loadWeather"
@change-location="handleLocationChange"
/>
</v-app-bar>
</template>
<style scoped>
.weather-chip {
cursor: pointer;
transition: transform 0.15s ease, background-color 0.15s ease;
}
.weather-chip:hover {
transform: translateY(-1px);
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
weather: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'refresh', 'change-location']);
const geoLoading = ref(false);
const isCustomLocation = computed(() => {
if (!props.weather?.location) return false;
const latDiff = Math.abs(props.weather.location.lat - 37.5665);
const lonDiff = Math.abs(props.weather.location.lon - 126.9780);
return latDiff > 0.1 || lonDiff > 0.1;
});
const getCurrentLocation = () => {
if (!navigator.geolocation) {
alert('현재 브라우저에서 위치 정보를 지원하지 않습니다.');
return;
}
geoLoading.value = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
geoLoading.value = false;
emit('change-location', {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
});
},
(err) => {
geoLoading.value = false;
alert('위치 정보를 가져올 수 없습니다. 브라우저 위치 권한을 허용했는지 확인해주세요.');
},
{ timeout: 8000 }
);
};
const resetToSeoul = () => {
emit('change-location', {
lat: 37.5665,
lon: 126.9780,
});
};
</script>
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
max-width="560"
scrollable
>
<v-card class="rounded-xl overflow-hidden">
<!-- 헤더 툴바 -->
<v-toolbar color="surface" density="comfortable" class="border-b px-2">
<v-toolbar-title class="text-subtitle-1 font-weight-bold d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-weather-partly-cloudy</v-icon>
날씨 예보
</v-toolbar-title>
<!-- 위치 배지 -->
<v-chip
v-if="weather?.location"
size="small"
color="primary"
variant="tonal"
class="mr-2 font-weight-bold"
>
<v-icon start size="14">mdi-map-marker</v-icon>
{{ weather.location.name }}
</v-chip>
<!-- GPS 위치 찾기 버튼 -->
<v-btn
icon="mdi-crosshairs-gps"
size="small"
variant="text"
:loading="geoLoading"
@click="getCurrentLocation"
title="현재 GPS 위치로 날씨 찾기"
></v-btn>
<!-- 새로고침 버튼 -->
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
:loading="loading"
@click="$emit('refresh')"
title="날씨 새로고침"
></v-btn>
<!-- 닫기 버튼 -->
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="$emit('update:modelValue', false)"
></v-btn>
</v-toolbar>
<v-card-text class="pa-4" v-if="weather">
<!-- 오늘 날씨 요약 카드 -->
<v-card
variant="flat"
class="mb-4 rounded-lg bg-surface-variant pa-4"
>
<div class="d-flex align-center justify-space-between flex-wrap">
<div>
<div class="text-caption text-medium-emphasis">
{{ weather.daily?.[0]?.formatted_date || '오늘' }} · {{ weather.location.name }}
</div>
<div class="d-flex align-baseline mt-1">
<span class="text-h3 font-weight-bold mr-2">
{{ weather.current.temperature }}°
</span>
<span class="text-body-1 font-weight-medium">
{{ weather.current.weather_text }}
</span>
</div>
<div class="text-caption text-medium-emphasis mt-1">
체감 온도 {{ weather.current.apparent_temperature }}°C
</div>
</div>
<!-- 오늘 날씨 아이콘 -->
<v-icon
:icon="weather.current.icon"
:color="weather.current.color"
size="64"
class="ma-2"
></v-icon>
</div>
<v-divider class="my-3"></v-divider>
<!-- 오늘의 상세 지표 -->
<v-row dense class="text-center">
<v-col cols="3">
<div class="text-caption text-medium-emphasis">습도</div>
<div class="text-subtitle-2 font-weight-bold mt-1 d-flex align-center justify-center">
<v-icon size="14" color="info" class="mr-1">mdi-water-percent</v-icon>
{{ weather.current.humidity }}%
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-medium-emphasis">풍속</div>
<div class="text-subtitle-2 font-weight-bold mt-1 d-flex align-center justify-center">
<v-icon size="14" color="teal" class="mr-1">mdi-weather-windy</v-icon>
{{ weather.current.wind_speed }} km/h
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-medium-emphasis">강수확률</div>
<div class="text-subtitle-2 font-weight-bold mt-1 d-flex align-center justify-center">
<v-icon size="14" color="blue" class="mr-1">mdi-umbrella</v-icon>
{{ weather.daily?.[0]?.precipitation_prob ?? 0 }}%
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-medium-emphasis">최저 / 최고</div>
<div class="text-subtitle-2 font-weight-bold mt-1">
<span class="text-blue font-weight-bold">{{ weather.daily?.[0]?.temp_min }}°</span> /
<span class="text-red font-weight-bold">{{ weather.daily?.[0]?.temp_max }}°</span>
</div>
</v-col>
</v-row>
</v-card>
<!-- 주간 예보 (7일) -->
<div class="text-subtitle-2 font-weight-bold mb-2 d-flex align-center">
<v-icon size="18" class="mr-1 text-primary">mdi-calendar-week</v-icon>
주간 날씨 예보 (7일)
</div>
<v-list density="compact" class="pa-0 rounded-lg border bg-surface">
<template v-for="(day, idx) in weather.daily" :key="day.date">
<v-list-item
:class="{'bg-primary-subtle': idx === 0}"
class="py-2 px-3"
>
<div class="d-flex align-center justify-space-between w-100">
<!-- 날짜 및 요일 -->
<div style="min-width: 90px;">
<span
class="font-weight-bold text-body-2 mr-1"
:class="{'text-primary': idx === 0}"
>
{{ day.day_label }}
</span>
<span class="text-caption text-medium-emphasis">
{{ day.date.substring(5).replace('-', '/') }}
</span>
</div>
<!-- 날씨 아이콘 & 텍스트 -->
<div class="d-flex align-center justify-center flex-grow-1 px-2" style="min-width: 110px;">
<v-icon :icon="day.icon" :color="day.color" size="22" class="mr-2"></v-icon>
<span class="text-caption font-weight-medium text-truncate" style="max-width: 80px;">
{{ day.weather_text }}
</span>
</div>
<!-- 강수 확률 -->
<div class="text-caption text-center mr-3" style="width: 50px;">
<span v-if="day.precipitation_prob > 0" class="text-blue font-weight-medium d-flex align-center justify-center">
<v-icon size="12" class="mr-0.5">mdi-water-percent</v-icon>
{{ day.precipitation_prob }}%
</span>
<span v-else class="text-disabled">-</span>
</div>
<!-- 최저 / 최고 기온 -->
<div class="text-right text-caption font-weight-bold" style="min-width: 85px;">
<span class="text-blue mr-1">{{ day.temp_min }}°</span>
<span class="text-disabled font-weight-regular mr-1">~</span>
<span class="text-red">{{ day.temp_max }}°</span>
</div>
</div>
</v-list-item>
<v-divider v-if="idx < weather.daily.length - 1"></v-divider>
</template>
</v-list>
<!-- 하단 정보 -->
<div class="d-flex justify-space-between align-center mt-3 text-caption text-disabled px-1">
<span>제공: Open-Meteo API</span>
<span>업데이트: {{ weather.updated_at }}</span>
</div>
</v-card-text>
<v-card-text v-else-if="loading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" class="mb-3"></v-progress-circular>
<div class="text-body-2 text-medium-emphasis">날씨 정보를 불러오는 중...</div>
</v-card-text>
<v-card-text v-else class="text-center py-8">
<v-icon size="40" color="error" class="mb-2">mdi-alert-circle-outline</v-icon>
<div class="text-body-2 text-medium-emphasis">날씨 정보를 불러오지 못했습니다.</div>
<v-btn size="small" variant="tonal" color="primary" class="mt-3" @click="$emit('refresh')">다시 시도</v-btn>
</v-card-text>
<v-card-actions class="px-4 py-2 border-t bg-surface">
<v-btn
v-if="isCustomLocation"
variant="text"
size="small"
color="secondary"
@click="resetToSeoul"
>
기본 위치(서울)로 복귀
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="text"
@click="$emit('update:modelValue', false)"
>
닫기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.bg-primary-subtle {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>