This commit is contained in:
1 parent
42008079db
commit
c86ec6a84f
10 files changed
+660
-47
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user