You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1052 lines
28 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<el-container class="layout command-layout">
<el-header class="command-header">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<i></i>
<b></b>
<em></em>
</span>
<strong>城市照明综合网管平台</strong>
</div>
<nav class="top-nav" aria-label="主导航">
<button
v-for="item in visibleNavItems"
:key="item.label"
class="nav-item"
:class="{ active: isActive(item) }"
type="button"
@click="router.push(item.path)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</button>
</nav>
<div class="header-tools">
<!-- <button class="tool-button" type="button" title="搜索">
<el-icon><Search /></el-icon>
</button>
<span class="divider"></span>
<button class="tool-button has-badge" type="button" title="告警通知">
<el-icon><Bell /></el-icon>
<sup>12</sup>
</button>
<button class="tool-button" type="button" title="全屏">
<el-icon><FullScreen /></el-icon>
</button> -->
<span class="date-time">{{ nowText }}</span>
<button class="weather" type="button" :title="weatherTitle" @click="handleWeatherClick" @dblclick.stop.prevent="openWeatherLocationDialog">
<i :class="weatherIconClass" aria-hidden="true"></i>
<span>{{ weatherText }}</span>
</button>
<el-dropdown>
<button class="user-button" type="button">
<el-icon><User /></el-icon>
<span>{{ authStore.user?.name ?? '用户' }}</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="passwordDialogVisible = true">修改密码</el-dropdown-item>
<el-dropdown-item @click="authStore.logout()">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
<el-dialog v-model="passwordDialogVisible" title="修改密码" width="420px">
<el-form :model="passwordForm" label-width="90px">
<el-form-item label="原密码">
<el-input v-model="passwordForm.oldPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="passwordForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="passwordForm.confirmPassword" type="password" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="passwordDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="changingPassword" @click="changePassword">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="weatherLocationDialogVisible" title="选择天气位置" width="480px">
<el-form :model="weatherLocationForm" label-width="80px">
<el-form-item label="地点">
<el-input v-model.trim="weatherLocationForm.keyword" placeholder="输入城市、区县或详细地址" @keyup.enter="resolveWeatherLocation">
<template #append>
<el-button :loading="weatherLocationSaving" @click="resolveWeatherLocation">解析</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="位置名称">
<el-input v-model.trim="weatherLocationForm.locationName" placeholder="用于悬浮提示展示" />
</el-form-item>
<el-form-item label="经纬度">
<div class="weather-location-grid">
<el-input v-model.trim="weatherLocationForm.longitude" placeholder="经度" />
<el-input v-model.trim="weatherLocationForm.latitude" placeholder="纬度" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="clearStoredWeatherLocation">清除保存</el-button>
<el-button :loading="weatherLocationSaving" @click="fillBrowserWeatherLocation">浏览器定位</el-button>
<el-button @click="weatherLocationDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="weatherLocationSaving" @click="saveWeatherLocation">保存并刷新</el-button>
</template>
</el-dialog>
</el-container>
</template>
<script setup lang="ts">
import { Bell, FullScreen, Monitor, Odometer, Search, Setting, Tools, User, Warning } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { authApi } from '../api/modules';
import { menuRoutes } from '../router';
import { useAuthStore } from '../stores/auth';
import { BEIJING_CENTER, getBrowserCoordinates, loadAmap } from '../utils/amap';
const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const passwordDialogVisible = ref(false);
const changingPassword = ref(false);
const weatherLocationDialogVisible = ref(false);
const weatherLocationSaving = ref(false);
const nowText = ref('');
const passwordForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' });
const weatherLocationForm = reactive({
keyword: '',
locationName: '',
longitude: '',
latitude: '',
});
let clockTimer: number | undefined;
let weatherTimer: number | undefined;
let weatherClickTimer: number | undefined;
const WEATHER_LOCATION_STORAGE_KEY = 'urbanLighting.weatherLocation';
interface WeatherState {
status: 'locating' | 'loading' | 'ready' | 'error';
temperature?: number;
weatherCode?: number;
updatedAt?: string;
latitude?: number;
longitude?: number;
locationName?: string;
errorText?: string;
}
interface WeatherLocationPreference {
longitude: number;
latitude: number;
locationName: string;
}
const weather = ref<WeatherState>({ status: 'locating' });
const visibleNavItems = computed(() =>
menuRoutes
.filter((item) => {
const permission = item.meta?.permission as string | undefined;
return !permission || authStore.permissions.includes(permission);
})
.map((item) => ({
label: String(item.meta?.title ?? ''),
path: item.path,
icon: routeIcon(item.path),
})),
);
const weatherText = computed(() => {
if (weather.value.status === 'locating') {
return '定位中';
}
if (weather.value.status === 'loading') {
return '天气获取中';
}
if (weather.value.status === 'error' || weather.value.temperature === undefined) {
return weather.value.errorText ?? '天气获取失败';
}
return `${weatherCodeText(weather.value.weatherCode)} ${Math.round(weather.value.temperature)}°C`;
});
const weatherTitle = computed(() => {
if (!weather.value.locationName && weather.value.longitude === undefined && weather.value.latitude === undefined) {
return weatherText.value;
}
const longitude = weather.value.longitude?.toFixed(4) ?? '-';
const latitude = weather.value.latitude?.toFixed(4) ?? '-';
const locationName = weather.value.locationName ?? '当前位置';
const updatedAt = weather.value.updatedAt ? `,更新时间:${weather.value.updatedAt}` : '';
return `实时天气:${locationName}(${longitude}, ${latitude}),${weatherText.value}${updatedAt}`;
});
const weatherIconClass = computed(() => {
const code = weather.value.weatherCode;
if (weather.value.status === 'error') {
return 'error';
}
if (code === undefined) {
return 'loading';
}
if ([0, 1].includes(code)) {
return 'sun';
}
if ([2, 3].includes(code)) {
return 'cloud';
}
if ([45, 48].includes(code)) {
return 'fog';
}
if ([95, 96, 99].includes(code)) {
return 'storm';
}
if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) {
return 'rain';
}
if ((code >= 71 && code <= 77) || (code >= 85 && code <= 86)) {
return 'snow';
}
return 'cloud';
});
function routeIcon(path: string) {
if (path === '/dashboard') {
return Odometer;
}
if (path === '/map' || path === '/lamps' || path === '/terminals') {
return Monitor;
}
if (path === '/sync') {
return Tools;
}
if (path === '/alarms') {
return Warning;
}
if (path.startsWith('/system/users')) {
return User;
}
return Setting;
}
function isActive(item: { path: string }) {
return route.path === item.path || route.path.startsWith(`${item.path}/`);
}
function refreshClock() {
const now = new Date();
const date = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replaceAll('/', '-');
const time = now.toLocaleTimeString('zh-CN', { hour12: false });
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
nowText.value = `${date} ${time} ${week}`;
}
function weatherCodeText(code?: number) {
if (code === undefined) {
return '实时天气';
}
const labels: Record<number, string> = {
0: '晴',
1: '晴间多云',
2: '多云',
3: '阴',
45: '雾',
48: '雾凇',
51: '毛毛雨',
53: '毛毛雨',
55: '毛毛雨',
56: '冻雨',
57: '冻雨',
61: '小雨',
63: '中雨',
65: '大雨',
66: '冻雨',
67: '冻雨',
71: '小雪',
73: '中雪',
75: '大雪',
77: '雪粒',
80: '阵雨',
81: '阵雨',
82: '强阵雨',
85: '阵雪',
86: '强阵雪',
95: '雷雨',
96: '雷雨冰雹',
99: '雷雨冰雹',
};
return labels[code] ?? '实时天气';
}
function formatBeijingDateTime(value?: string) {
if (value) {
const beijingTime = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/);
if (beijingTime && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(value)) {
return `${beijingTime[1]}-${beijingTime[2]}-${beijingTime[3]} ${beijingTime[4]}:${beijingTime[5]}:${beijingTime[6] ?? '00'}`;
}
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
return formatDateInBeijing(date);
}
}
return formatDateInBeijing(new Date());
}
function formatDateInBeijing(date: Date) {
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date);
const getPart = (type: string) => parts.find((part) => part.type === type)?.value ?? '00';
return `${getPart('year')}-${getPart('month')}-${getPart('day')} ${getPart('hour')}:${getPart('minute')}:${getPart('second')}`;
}
function refreshWeather() {
if (weather.value.status === 'locating' || weather.value.status === 'loading') {
return;
}
void loadWeather();
}
function handleWeatherClick() {
if (weatherClickTimer) {
window.clearTimeout(weatherClickTimer);
weatherClickTimer = undefined;
return;
}
weatherClickTimer = window.setTimeout(() => {
weatherClickTimer = undefined;
refreshWeather();
}, 240);
}
function openWeatherLocationDialog() {
if (weatherClickTimer) {
window.clearTimeout(weatherClickTimer);
weatherClickTimer = undefined;
}
const storedLocation = readStoredWeatherLocation();
const longitude = storedLocation?.longitude ?? weather.value.longitude ?? BEIJING_CENTER[0];
const latitude = storedLocation?.latitude ?? weather.value.latitude ?? BEIJING_CENTER[1];
const locationName = storedLocation?.locationName ?? weather.value.locationName ?? '北京市';
weatherLocationForm.keyword = locationName;
weatherLocationForm.locationName = locationName;
weatherLocationForm.longitude = String(longitude);
weatherLocationForm.latitude = String(latitude);
weatherLocationDialogVisible.value = true;
}
function readStoredWeatherLocation(): WeatherLocationPreference | null {
try {
const raw = window.localStorage.getItem(WEATHER_LOCATION_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as Partial<WeatherLocationPreference>;
const longitude = Number(parsed.longitude);
const latitude = Number(parsed.latitude);
if (!isValidLongitude(longitude) || !isValidLatitude(latitude)) {
window.localStorage.removeItem(WEATHER_LOCATION_STORAGE_KEY);
return null;
}
return {
longitude,
latitude,
locationName: typeof parsed.locationName === 'string' && parsed.locationName.trim() ? parsed.locationName.trim() : '自定义位置',
};
} catch {
return null;
}
}
function writeStoredWeatherLocation(location: WeatherLocationPreference) {
window.localStorage.setItem(WEATHER_LOCATION_STORAGE_KEY, JSON.stringify(location));
}
function isValidLongitude(value: number) {
return Number.isFinite(value) && value >= -180 && value <= 180;
}
function isValidLatitude(value: number) {
return Number.isFinite(value) && value >= -90 && value <= 90;
}
function getWeatherLocationFromForm() {
const longitude = Number(weatherLocationForm.longitude);
const latitude = Number(weatherLocationForm.latitude);
if (!isValidLongitude(longitude) || !isValidLatitude(latitude)) {
ElMessage.warning('请输入有效的经纬度');
return null;
}
return {
longitude,
latitude,
locationName: weatherLocationForm.locationName.trim() || weatherLocationForm.keyword.trim() || '自定义位置',
};
}
async function resolveWeatherLocation() {
const keyword = weatherLocationForm.keyword.trim();
if (!keyword) {
ElMessage.warning('请输入地点名称');
return;
}
weatherLocationSaving.value = true;
try {
const location = await geocodeAddress(keyword);
weatherLocationForm.longitude = String(location.longitude);
weatherLocationForm.latitude = String(location.latitude);
weatherLocationForm.locationName = location.locationName;
} catch {
ElMessage.error('地点解析失败,请手动输入经纬度');
} finally {
weatherLocationSaving.value = false;
}
}
async function fillBrowserWeatherLocation() {
weatherLocationSaving.value = true;
try {
const [longitude, latitude] = await getBrowserCoordinates();
const locationName = await reverseGeocode(longitude, latitude);
weatherLocationForm.keyword = locationName;
weatherLocationForm.locationName = locationName;
weatherLocationForm.longitude = String(longitude);
weatherLocationForm.latitude = String(latitude);
} catch {
ElMessage.warning('浏览器定位失败');
} finally {
weatherLocationSaving.value = false;
}
}
async function saveWeatherLocation() {
const formLocation = getWeatherLocationFromForm();
if (!formLocation) {
return;
}
weatherLocationSaving.value = true;
try {
const locationName = formLocation.locationName === '自定义位置'
? await reverseGeocode(formLocation.longitude, formLocation.latitude)
: formLocation.locationName;
writeStoredWeatherLocation({ ...formLocation, locationName });
weatherLocationDialogVisible.value = false;
ElMessage.success('天气位置已保存');
void loadWeather();
} catch {
ElMessage.error('天气位置保存失败');
} finally {
weatherLocationSaving.value = false;
}
}
function clearStoredWeatherLocation() {
window.localStorage.removeItem(WEATHER_LOCATION_STORAGE_KEY);
weatherLocationDialogVisible.value = false;
ElMessage.success('已清除保存的天气位置');
void loadWeather();
}
async function loadWeather() {
weather.value = { status: 'locating' };
let longitude = 0;
let latitude = 0;
let locationName = '';
const storedLocation = readStoredWeatherLocation();
if (storedLocation) {
longitude = storedLocation.longitude;
latitude = storedLocation.latitude;
locationName = storedLocation.locationName;
} else {
try {
[longitude, latitude] = await getBrowserCoordinates();
locationName = await reverseGeocode(longitude, latitude);
} catch {
[longitude, latitude] = BEIJING_CENTER;
locationName = '北京市';
}
}
const params = new URLSearchParams({
latitude: String(latitude),
longitude: String(longitude),
current: 'temperature_2m,weather_code',
timezone: 'Asia/Shanghai',
});
weather.value = { status: 'loading', latitude, longitude, locationName };
try {
const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`weather request failed: ${response.status}`);
}
const data = await response.json();
const current = data.current ?? {};
const temperature = Number(current.temperature_2m);
const weatherCode = Number(current.weather_code);
if (!Number.isFinite(temperature) || !Number.isFinite(weatherCode)) {
throw new Error('weather response missing current data');
}
weather.value = {
status: 'ready',
temperature,
weatherCode,
updatedAt: formatBeijingDateTime(current.time),
latitude,
longitude,
locationName,
};
} catch {
weather.value = { status: 'error', errorText: '天气获取失败', latitude, longitude, locationName };
}
}
async function geocodeAddress(keyword: string) {
const AMap = await loadAmap();
await withTimeout(
new Promise<void>((resolve, reject) => {
AMap.plugin('AMap.Geocoder', () => {
if (AMap.Geocoder) {
resolve();
return;
}
reject(new Error('AMap.Geocoder unavailable'));
});
}),
4000,
);
const geocoder = new AMap.Geocoder();
const result = await withTimeout(
new Promise<any>((resolve, reject) => {
geocoder.getLocation(keyword, (status: string, response: any) => {
const geocode = response?.geocodes?.[0];
if (status === 'complete' && geocode?.location) {
resolve(geocode);
return;
}
reject(new Error('geocode failed'));
});
}),
5000,
);
const longitude = Number(result.location.lng);
const latitude = Number(result.location.lat);
if (!isValidLongitude(longitude) || !isValidLatitude(latitude)) {
throw new Error('invalid geocode result');
}
return {
longitude,
latitude,
locationName: result.formattedAddress || keyword,
};
}
async function reverseGeocode(longitude: number, latitude: number) {
try {
const AMap = await loadAmap();
await withTimeout(
new Promise<void>((resolve, reject) => {
AMap.plugin('AMap.Geocoder', () => {
if (AMap.Geocoder) {
resolve();
return;
}
reject(new Error('AMap.Geocoder unavailable'));
});
}),
4000,
);
const geocoder = new AMap.Geocoder();
const result = await withTimeout(
new Promise<any>((resolve, reject) => {
geocoder.getAddress([longitude, latitude], (status: string, response: any) => {
if (status === 'complete' && response?.regeocode) {
resolve(response.regeocode);
return;
}
reject(new Error('reverse geocode failed'));
});
}),
5000,
);
const component = result.addressComponent ?? {};
return [component.province, component.city, component.district].filter(Boolean).join('') || result.formattedAddress || '当前位置';
} catch {
return '当前位置';
}
}
function withTimeout<T>(promise: Promise<T>, milliseconds: number) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('request timeout')), milliseconds);
promise
.then((value) => {
window.clearTimeout(timer);
resolve(value);
})
.catch((error) => {
window.clearTimeout(timer);
reject(error);
});
});
}
async function changePassword() {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.warning('两次输入的新密码不一致');
return;
}
changingPassword.value = true;
try {
await authApi.changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword,
});
ElMessage.success('密码修改成功,请重新登录');
passwordDialogVisible.value = false;
passwordForm.oldPassword = '';
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
authStore.logout();
} finally {
changingPassword.value = false;
}
}
onMounted(() => {
refreshClock();
void loadWeather();
clockTimer = window.setInterval(refreshClock, 1000);
weatherTimer = window.setInterval(() => void loadWeather(), 10 * 60 * 1000);
});
onBeforeUnmount(() => {
if (clockTimer) {
window.clearInterval(clockTimer);
}
if (weatherTimer) {
window.clearInterval(weatherTimer);
}
if (weatherClickTimer) {
window.clearTimeout(weatherClickTimer);
}
});
</script>
<style scoped>
.layout {
min-height: 100vh;
overflow: hidden;
background:
radial-gradient(circle at 21% 12%, rgba(0, 141, 255, 0.24), transparent 25%),
radial-gradient(circle at 78% 8%, rgba(37, 99, 235, 0.18), transparent 26%),
linear-gradient(180deg, #05152d 0%, #031126 46%, #020916 100%);
}
.command-header {
--header-height: 58px;
display: grid;
grid-template-columns: 300px minmax(0, 1fr) auto;
align-items: center;
height: var(--header-height);
padding: 0 18px;
border-bottom: 1px solid rgba(26, 137, 255, 0.36);
background:
linear-gradient(180deg, rgba(2, 19, 46, 0.96), rgba(3, 14, 35, 0.86)),
linear-gradient(90deg, rgba(33, 150, 243, 0.16), transparent 45%, rgba(33, 150, 243, 0.12));
box-shadow: 0 10px 36px rgba(0, 12, 35, 0.42), inset 0 -1px rgba(255, 255, 255, 0.08);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
color: #f8fbff;
}
.brand strong {
overflow: hidden;
font-size: 22px;
font-weight: 800;
letter-spacing: 0;
text-overflow: ellipsis;
text-shadow: 0 0 16px rgba(65, 170, 255, 0.48);
white-space: nowrap;
}
.brand-mark {
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: flex-end;
width: 40px;
height: 40px;
padding: 4px 5px;
color: #39d7ff;
border-bottom: 2px solid currentColor;
filter: drop-shadow(0 0 10px rgba(35, 162, 255, 0.72));
}
.brand-mark i,
.brand-mark b,
.brand-mark em {
display: block;
width: 6px;
margin-right: 3px;
border: 1px solid currentColor;
background: linear-gradient(180deg, rgba(86, 196, 255, 0.95), rgba(15, 87, 208, 0.36));
box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.28);
}
.brand-mark i {
height: 19px;
}
.brand-mark b {
height: 28px;
}
.brand-mark em {
height: 35px;
margin-right: 0;
}
.top-nav {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.top-nav::-webkit-scrollbar {
display: none;
}
.nav-item,
.tool-button,
.user-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
color: #b9c9de;
background: transparent;
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.nav-item {
position: relative;
flex: 0 0 auto;
gap: 8px;
height: 42px;
padding: 0 18px;
font-size: 15px;
font-weight: 700;
white-space: nowrap;
}
.nav-item::after {
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #2dd4ff, transparent);
opacity: 0;
content: "";
}
.nav-item:hover,
.nav-item.active {
color: #fff;
background: linear-gradient(180deg, rgba(20, 86, 179, 0.72), rgba(5, 32, 82, 0.26));
box-shadow: inset 0 0 18px rgba(38, 145, 255, 0.26), 0 0 18px rgba(17, 104, 255, 0.12);
}
.nav-item.active::after {
opacity: 1;
}
.nav-item .el-icon {
font-size: 18px;
}
.header-tools {
display: flex;
align-items: center;
gap: 10px;
justify-self: end;
color: #d7e6f8;
}
.tool-button {
position: relative;
width: 30px;
height: 30px;
border-radius: 4px;
font-size: 18px;
}
.tool-button:hover,
.user-button:hover {
color: #fff;
background: rgba(28, 129, 255, 0.18);
}
.tool-button sup {
position: absolute;
top: -5px;
right: -5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 999px;
color: #fff;
background: #ff4d40;
font-size: 11px;
line-height: 17px;
}
.divider {
width: 1px;
height: 24px;
background: rgba(148, 184, 220, 0.28);
}
.date-time {
color: #d8e7f9;
font-family: "DIN Alternate", "Roboto Mono", monospace;
font-size: 14px;
white-space: nowrap;
}
.weather {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 88px;
padding: 0;
border: 0;
background: transparent;
color: #e6f4ff;
cursor: pointer;
font: inherit;
font-size: 14px;
white-space: nowrap;
}
.weather:focus-visible {
outline: 1px solid rgba(53, 215, 255, 0.86);
outline-offset: 4px;
}
.weather i {
position: relative;
flex: 0 0 auto;
width: 22px;
height: 14px;
}
.weather i::before,
.weather i::after {
position: absolute;
content: "";
}
.weather i.sun::before {
inset: 1px 5px;
border-radius: 50%;
background: #ffd45a;
box-shadow: 0 0 12px rgba(255, 212, 90, 0.88);
}
.weather i.sun::after {
inset: -2px 2px;
border: 1px dashed rgba(255, 212, 90, 0.85);
border-radius: 50%;
}
.weather i.cloud::before,
.weather i.rain::before,
.weather i.snow::before,
.weather i.storm::before,
.weather i.fog::before {
right: 0;
bottom: 2px;
left: 2px;
height: 10px;
border-radius: 999px;
background: #f7fbff;
box-shadow: -7px 1px 0 -3px #f7fbff, 7px 1px 0 -2px #f7fbff, 0 0 10px rgba(156, 218, 255, 0.58);
}
.weather i.rain::after {
right: 3px;
bottom: -3px;
width: 2px;
height: 8px;
border-radius: 999px;
background: #35d7ff;
box-shadow: -7px -1px 0 #35d7ff, -14px 0 0 #35d7ff;
transform: rotate(18deg);
}
.weather i.snow::after {
right: 4px;
bottom: -4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #dff7ff;
box-shadow: -8px -1px 0 #dff7ff, -15px 1px 0 #dff7ff;
}
.weather i.storm::after {
right: 5px;
bottom: -6px;
width: 8px;
height: 12px;
clip-path: polygon(44% 0, 100% 0, 58% 45%, 100% 45%, 22% 100%, 42% 54%, 0 54%);
background: #ffd45a;
}
.weather i.fog::after {
right: 1px;
bottom: -2px;
left: 0;
height: 2px;
border-radius: 999px;
background: #bfe6ff;
box-shadow: 0 5px 0 rgba(191, 230, 255, 0.85);
}
.weather i.loading::before,
.weather i.error::before {
inset: 1px 5px;
border: 2px solid rgba(180, 213, 248, 0.45);
border-top-color: #35d7ff;
border-radius: 50%;
}
.weather i.loading::before {
animation: weather-spin 1s linear infinite;
}
.weather i.error::before {
border-color: #ff6b4a;
}
.weather span {
overflow: hidden;
text-overflow: ellipsis;
}
.weather-location-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: 100%;
}
@keyframes weather-spin {
to {
transform: rotate(360deg);
}
}
.user-button {
gap: 6px;
height: 32px;
max-width: 120px;
padding: 0 10px;
border: 1px solid rgba(72, 164, 255, 0.22);
border-radius: 4px;
font-size: 13px;
}
.user-button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main {
height: calc(100vh - 58px);
padding: 0;
overflow: auto;
}
@media (max-width: 1280px) {
.command-header {
grid-template-columns: 260px minmax(0, 1fr) auto;
padding: 0 12px;
}
.nav-item {
padding: 0 11px;
font-size: 14px;
}
.date-time,
.weather {
display: none;
}
}
@media (max-width: 860px) {
.command-header {
grid-template-columns: 1fr auto;
height: auto;
min-height: 58px;
row-gap: 8px;
padding: 10px 12px;
}
.top-nav {
grid-column: 1 / -1;
order: 3;
overflow-x: auto;
padding-bottom: 4px;
}
.main {
height: calc(100vh - 112px);
}
.brand strong {
font-size: 18px;
}
}
</style>