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