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:
2026-04-06 22:18:44 +08:00
parent ff042cd932
commit 712d9e1652
9 changed files with 133 additions and 22 deletions

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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'

View File

@@ -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);

View File

@@ -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}&current=temperature_2m,weather_code&timezone=auto`, `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=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
} }
} }

View File

@@ -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>