feat(frontend): add weather icons and redesign calendar header
Backend changes: - Add LOCATION configuration option to Settings - Add /api/system/config endpoint to expose public config - Implement location priority: config > geolocation > default Frontend changes: - Install and integrate weather-icons npm package (Erik Flowers) - Redesign calendar header with date/time on left, weather/location on right - Display weather icon using CSS classes instead of SVG components - Fetch location from backend API on component mount - Use configured location name (from .env) instead of geocoded result Layout: - Left: month/year + current time - Right: city name + weather description + weather icon
This commit is contained in:
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
|
|||||||
DAILY_PLAN_TIME: str = "00:00"
|
DAILY_PLAN_TIME: str = "00:00"
|
||||||
FORUM_SCAN_INTERVAL_MINUTES: int = 30
|
FORUM_SCAN_INTERVAL_MINUTES: int = 30
|
||||||
|
|
||||||
|
# === 位置配置 ===
|
||||||
|
LOCATION: str = "Location"
|
||||||
|
|
||||||
# === CORS ===
|
# === CORS ===
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,9 @@ router = APIRouter(prefix='/api/system', tags=['system'])
|
|||||||
@router.get('/status')
|
@router.get('/status')
|
||||||
async def get_system_status():
|
async def get_system_status():
|
||||||
return SystemService().get_status()
|
return SystemService().get_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/config')
|
||||||
|
async def get_system_config():
|
||||||
|
"""Get public system configuration."""
|
||||||
|
return SystemService().get_config()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -15,6 +16,11 @@ class SystemService:
|
|||||||
_last_net_bytes_recv: int | None = None
|
_last_net_bytes_recv: int | None = None
|
||||||
_last_net_sample_at: float | None = None
|
_last_net_sample_at: float | None = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Import settings here to avoid circular imports
|
||||||
|
from app.config import settings
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
def _get_network_rates(self) -> tuple[float, float]:
|
def _get_network_rates(self) -> tuple[float, float]:
|
||||||
counters = psutil.net_io_counters()
|
counters = psutil.net_io_counters()
|
||||||
now = monotonic()
|
now = monotonic()
|
||||||
@@ -127,3 +133,9 @@ class SystemService:
|
|||||||
**gpu_status,
|
**gpu_status,
|
||||||
'timestamp': datetime.now(UTC).isoformat(),
|
'timestamp': datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_config(self) -> dict:
|
||||||
|
"""Get public system configuration."""
|
||||||
|
return {
|
||||||
|
'location': self._settings.LOCATION,
|
||||||
|
}
|
||||||
|
|||||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"weather-icons": "^1.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
@@ -3853,6 +3854,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/weather-icons": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/weather-icons/-/weather-icons-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-EW/i0gFzlPpyBK29iXtruDw1v9txI4Gjp7q6ikbFTsL6O9gt3yH64/fuMf7wxpj92v5I08JhajBra/wMn715Eg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"3d-force-graph": "^1.79.0",
|
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
|
"3d-force-graph": "^1.79.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"weather-icons": "^1.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
import 'weather-icons/css/weather-icons.css'
|
||||||
import router from './app/router'
|
import router from './app/router'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|||||||
@@ -78,7 +78,9 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-icon {
|
.weather-icon,
|
||||||
|
.weather-inline .wi {
|
||||||
|
font-size: 16px;
|
||||||
color: #7dd3fc;
|
color: #7dd3fc;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -2086,6 +2088,44 @@
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jarvis-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: rgba(223, 247, 255, 0.9);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(223, 247, 255, 0.7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-weather-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: rgba(160, 232, 255, 0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.jarvis-date-num {
|
.jarvis-date-num {
|
||||||
min-width: 62px;
|
min-width: 62px;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
export function formatNetworkRate(bytesPerSecond: number | null, online: boolean) {
|
export function formatNetworkRate(bytesPerSecond: number | null, online: boolean) {
|
||||||
if (!online || bytesPerSecond === null) return 'OFFLINE'
|
if (!online || bytesPerSecond === null) return 'OFFLINE'
|
||||||
@@ -10,10 +9,24 @@ export function formatNetworkRate(bytesPerSecond: number | null, online: boolean
|
|||||||
|
|
||||||
export function useClientTime() {
|
export function useClientTime() {
|
||||||
const clientTime = ref(new Date())
|
const clientTime = ref(new Date())
|
||||||
|
const city = ref('Location')
|
||||||
const weatherSummary = ref('Weather unavailable')
|
const weatherSummary = ref('Weather unavailable')
|
||||||
const weatherCode = ref<number | null>(null)
|
const weatherCode = ref<number | null>(null)
|
||||||
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
|
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// Load location from backend config
|
||||||
|
async function loadLocation() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/system/config')
|
||||||
|
if (response.ok) {
|
||||||
|
const config = await response.json()
|
||||||
|
city.value = config.location || 'Location'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
city.value = import.meta.env.VITE_LOCATION || 'Location'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateClientTime() {
|
function updateClientTime() {
|
||||||
clientTime.value = new Date()
|
clientTime.value = new Date()
|
||||||
}
|
}
|
||||||
@@ -40,27 +53,48 @@ export function useClientTime() {
|
|||||||
|
|
||||||
const weatherIcon = computed(() => {
|
const weatherIcon = computed(() => {
|
||||||
const code = weatherCode.value
|
const code = weatherCode.value
|
||||||
if (code === 0) return Sun
|
if (code === 0) return 'wi-day-sunny'
|
||||||
if (code === 1 || code === 2 || code === 3) return Cloud
|
if (code === 1) return 'wi-day-cloudy'
|
||||||
if (code === 45 || code === 48) return CloudFog
|
if (code === 2) return 'wi-day-cloudy-gusts'
|
||||||
if ([51, 53, 55, 56, 57].includes(code ?? -1)) return CloudDrizzle
|
if (code === 3) return 'wi-cloudy'
|
||||||
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return CloudRain
|
if (code === 45) return 'wi-day-fog'
|
||||||
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return CloudSnow
|
if (code === 48) return 'wi-fog'
|
||||||
if ([95, 96, 99].includes(code ?? -1)) return CloudLightning
|
if ([51, 53].includes(code ?? -1)) return 'wi-day-showers'
|
||||||
return Cloud
|
if ([55, 56, 57].includes(code ?? -1)) return 'wi-showers'
|
||||||
|
if ([61, 63, 65, 80].includes(code ?? -1)) return 'wi-day-rain'
|
||||||
|
if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain'
|
||||||
|
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow'
|
||||||
|
if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm'
|
||||||
|
return 'wi-day-sunny'
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadWeather(latitude: number, longitude: number) {
|
async function loadWeather(latitude: number, longitude: number) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
// Fetch weather data
|
||||||
|
const weatherResp = await fetch(
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code&timezone=auto`,
|
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code&timezone=auto`,
|
||||||
)
|
)
|
||||||
if (!response.ok) throw new Error('weather request failed')
|
if (!weatherResp.ok) throw new Error('weather request failed')
|
||||||
const data = await response.json()
|
const weatherData = await weatherResp.json()
|
||||||
const current = data.current ?? {}
|
const current = weatherData.current ?? {}
|
||||||
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
|
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
|
||||||
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
|
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
|
||||||
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
|
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
|
||||||
|
|
||||||
|
// Only fetch city name if not already set by config
|
||||||
|
if (city.value === 'Location') {
|
||||||
|
try {
|
||||||
|
const geoResp = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10`,
|
||||||
|
)
|
||||||
|
if (geoResp.ok) {
|
||||||
|
const geoData = await geoResp.json()
|
||||||
|
city.value = geoData.address?.city ?? geoData.address?.town ?? geoData.address?.county ?? geoData.display_name?.split(',')[0] ?? 'Location'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
city.value = 'Location'
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
weatherCode.value = null
|
weatherCode.value = null
|
||||||
weatherSummary.value = 'Weather unavailable'
|
weatherSummary.value = 'Weather unavailable'
|
||||||
@@ -70,6 +104,7 @@ export function useClientTime() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
updateClientTime()
|
updateClientTime()
|
||||||
clientTimeTimer = setInterval(updateClientTime, 1000)
|
clientTimeTimer = setInterval(updateClientTime, 1000)
|
||||||
|
void loadLocation()
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
weatherCode.value = null
|
weatherCode.value = null
|
||||||
weatherSummary.value = 'Weather unavailable'
|
weatherSummary.value = 'Weather unavailable'
|
||||||
@@ -87,7 +122,7 @@ export function useClientTime() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientTime, weatherSummary, weatherCode, weatherIcon,
|
clientTime, city, weatherSummary, weatherCode, weatherIcon,
|
||||||
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather
|
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const {
|
|||||||
} = useKnowledgeView()
|
} = useKnowledgeView()
|
||||||
|
|
||||||
// --- Client time & weather ---
|
// --- Client time & weather ---
|
||||||
const { clientTime, weatherIcon, weatherSummary, formatClientDate, formatClientClock } = useClientTime()
|
const { clientTime, city, weatherIcon, weatherSummary, formatClientDate, formatClientClock } = useClientTime()
|
||||||
|
|
||||||
// --- Daily digest & reminders ---
|
// --- Daily digest & reminders ---
|
||||||
const { dailyDigest, digestLoading, activeReminder, reminderVisible, loadDailyDigest, handleSnooze, handleDismiss } = useDailyDigest()
|
const { dailyDigest, digestLoading, activeReminder, reminderVisible, loadDailyDigest, handleSnooze, handleDismiss } = useDailyDigest()
|
||||||
@@ -228,11 +228,17 @@ function renderMarkdown(content: string) {
|
|||||||
<div v-else class="jarvis-sidebar-scroll">
|
<div v-else class="jarvis-sidebar-scroll">
|
||||||
<div class="jarvis-panel jarvis-date-panel">
|
<div class="jarvis-panel jarvis-date-panel">
|
||||||
<div class="jarvis-date-row">
|
<div class="jarvis-date-row">
|
||||||
<div class="jarvis-date-num">{{ clientTime.getDate().toString().padStart(2, '0') }}</div>
|
|
||||||
<div class="jarvis-date-meta">
|
<div class="jarvis-date-meta">
|
||||||
<div class="jarvis-month">{{ clientTime.toLocaleString('zh-CN', { month: 'long' }) }} / {{ clientTime.getFullYear() }}</div>
|
<div class="jarvis-month">{{ clientTime.toLocaleString('zh-CN', { month: 'long' }) }} {{ clientTime.getFullYear() }}</div>
|
||||||
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('zh-CN', { hour12: false }) }}</div>
|
<div class="jarvis-time">{{ clientTime.toLocaleTimeString('zh-CN', { hour12: false }) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="jarvis-location">
|
||||||
|
<div class="location-info">
|
||||||
|
<span class="location-name">{{ city }}</span>
|
||||||
|
<span class="location-weather-text">{{ weatherSummary }}</span>
|
||||||
|
</div>
|
||||||
|
<i :class="['wi', weatherIcon, 'location-icon']"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="jarvis-calendar">
|
<div class="jarvis-calendar">
|
||||||
@@ -564,7 +570,7 @@ function renderMarkdown(content: string) {
|
|||||||
<div class="runtime-meta-item">
|
<div class="runtime-meta-item">
|
||||||
<span class="runtime-meta-key">WEATHER</span>
|
<span class="runtime-meta-key">WEATHER</span>
|
||||||
<span class="runtime-meta-value weather-inline">
|
<span class="runtime-meta-value weather-inline">
|
||||||
<component :is="weatherIcon" :size="14" class="weather-icon" />
|
<i :class="['wi', weatherIcon]"></i>
|
||||||
<span>{{ weatherSummary }}</span>
|
<span>{{ weatherSummary }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user