From 78214cac5a6b9312856e0b486c97f6eb0cfa19b1 Mon Sep 17 00:00:00 2001 From: Ruochen Pan Date: Fri, 7 Aug 2026 11:25:51 +0800 Subject: [PATCH] feat: show effective AstrBot time in settings (#9581) --- astrbot/dashboard/services/config_service.py | 25 +++- .../i18n/locales/en-US/features/settings.json | 3 + .../i18n/locales/ru-RU/features/settings.json | 3 + .../i18n/locales/zh-CN/features/settings.json | 3 + dashboard/src/views/Settings.vue | 116 +++++++++++++++++- tests/unit/test_config_profile_service.py | 25 ++++ 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_config_profile_service.py diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index 3caa8acd9..803b6b92e 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -5,8 +5,10 @@ import copy import inspect import os import traceback +from datetime import datetime, timezone from pathlib import Path from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from astrbot.core import file_token_service, logger from astrbot.core.config.astrbot_config import AstrBotConfig @@ -474,7 +476,28 @@ class ConfigProfileService: } def get_system_config(self) -> dict: - return self.get_system_schema() + """Return the system configuration with the server's effective time. + + Returns: + System configuration metadata, an aware UTC timestamp, and the + effective UTC offset in minutes. + """ + data = self.get_system_schema() + server_utc_time = datetime.now(timezone.utc) + timezone_name = str(data["config"].get("timezone") or "").strip() + if timezone_name: + try: + configured_time = server_utc_time.astimezone(ZoneInfo(timezone_name)) + except (ValueError, ZoneInfoNotFoundError): + configured_time = server_utc_time.astimezone() + else: + configured_time = server_utc_time.astimezone() + utc_offset = configured_time.utcoffset() + data["server_utc_time"] = server_utc_time.isoformat() + data["server_utc_offset_minutes"] = ( + int(utc_offset.total_seconds() / 60) if utc_offset else 0 + ) + return data def list_profiles(self) -> dict: return {"info_list": self.acm.get_conf_list()} diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index b45649b91..b3d9f7df0 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -67,6 +67,9 @@ "subtitle": "Rendering strategy, endpoint, and custom templates for text-to-image." } }, + "timePreview": { + "label": "AstrBot current time" + }, "messages": { "loadFailed": "Failed to load system config", "saveSuccess": "System config saved", diff --git a/dashboard/src/i18n/locales/ru-RU/features/settings.json b/dashboard/src/i18n/locales/ru-RU/features/settings.json index 9b1ceb946..b034f46c7 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/settings.json +++ b/dashboard/src/i18n/locales/ru-RU/features/settings.json @@ -67,6 +67,9 @@ "subtitle": "Стратегия, endpoint и пользовательские шаблоны для рендеринга." } }, + "timePreview": { + "label": "Текущее время AstrBot" + }, "messages": { "loadFailed": "Не удалось загрузить системную конфигурацию", "saveSuccess": "Системная конфигурация сохранена", diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index bf6e94a9b..d657039fd 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -67,6 +67,9 @@ "subtitle": "文本转图像渲染策略、端点和自定义模板。" } }, + "timePreview": { + "label": "AstrBot 当前时间" + }, "messages": { "loadFailed": "加载系统配置失败", "saveSuccess": "系统配置已保存", diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 7477f37e7..1ed19d96b 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -64,7 +64,20 @@ :class="{ 'system-config-group--with-cleanup': group.key === 'tempStorage' }" @focusout.capture="scheduleSystemConfigAutoSave" > -
{{ group.title }}
+
+
{{ group.title }}
+
+ + {{ tm('systemConfig.timePreview.label') }} · + {{ timezoneTimePreview.offsetLabel }} {{ timezoneTimePreview.localTime }} + +
+
( JSON.stringify(systemConfigData.value || {}) !== systemConfigLastSavedSnapshot.value )); +const timezoneTimePreview = computed(() => { + if ( + serverUtcEpochMs.value === null || + serverUtcOffsetMinutes.value === null || + serverClockAnchorMs.value === null || + serverClockTickMs.value === null + ) { + return null; + } + + const localDate = new Date( + serverUtcEpochMs.value + + Math.max(0, serverClockTickMs.value - serverClockAnchorMs.value) + + serverUtcOffsetMinutes.value * 60_000 + ); + const localIsoTime = localDate.toISOString(); + const absoluteOffset = Math.abs(serverUtcOffsetMinutes.value); + const offsetHours = Math.floor(absoluteOffset / 60); + const offsetMinutes = absoluteOffset % 60; + const offsetLabel = serverUtcOffsetMinutes.value === 0 + ? 'UTC' + : `UTC${serverUtcOffsetMinutes.value > 0 ? '+' : '-'}${offsetHours}${offsetMinutes ? `:${String(offsetMinutes).padStart(2, '0')}` : ''}`; + return { + localTime: `${localIsoTime.slice(0, 4)}/${localIsoTime.slice(5, 7)}/${localIsoTime.slice(8, 10)} ${localIsoTime.slice(11, 16)}`, + offsetLabel + }; +}); + const systemConfigGroups = computed(() => { const systemSection = systemConfigMetadata.value?.system_group?.metadata?.system || {}; const systemItems = systemSection.items || {}; @@ -820,6 +866,12 @@ const loadSystemConfig = async () => { } systemConfigData.value = res.data.data?.config || {}; systemConfigMetadata.value = res.data.data?.metadata || {}; + const parsedServerTime = Date.parse(res.data.data?.server_utc_time || ''); + const parsedUtcOffset = Number(res.data.data?.server_utc_offset_minutes); + serverUtcEpochMs.value = Number.isNaN(parsedServerTime) ? null : parsedServerTime; + serverUtcOffsetMinutes.value = Number.isFinite(parsedUtcOffset) ? parsedUtcOffset : null; + serverClockAnchorMs.value = serverUtcEpochMs.value === null ? null : performance.now(); + serverClockTickMs.value = serverClockAnchorMs.value; systemConfigLastSavedSnapshot.value = JSON.stringify(systemConfigData.value || {}); systemConfigRestartRequired.value = false; } catch (error) { @@ -864,6 +916,23 @@ const saveSystemConfig = async (configOverride = null, headers = {}, allow2faPro configSave2faError.value = ''; systemConfigData.value = configPayload; systemConfigLastSavedSnapshot.value = JSON.stringify(configPayload); + serverUtcEpochMs.value = null; + serverUtcOffsetMinutes.value = null; + serverClockAnchorMs.value = null; + serverClockTickMs.value = null; + try { + const timeRes = await systemConfigApi.get(); + if (timeRes.data.status === 'ok') { + const parsedServerTime = Date.parse(timeRes.data.data?.server_utc_time || ''); + const parsedUtcOffset = Number(timeRes.data.data?.server_utc_offset_minutes); + serverUtcEpochMs.value = Number.isNaN(parsedServerTime) ? null : parsedServerTime; + serverUtcOffsetMinutes.value = Number.isFinite(parsedUtcOffset) ? parsedUtcOffset : null; + serverClockAnchorMs.value = serverUtcEpochMs.value === null ? null : performance.now(); + serverClockTickMs.value = serverClockAnchorMs.value; + } + } catch { + // Keep the preview hidden until the backend time can be confirmed. + } systemConfigRestartRequired.value = true; showToast(res.data.message || tm('systemConfig.messages.saveSuccess'), 'success'); return { success: true }; @@ -1069,6 +1138,9 @@ const resetThemeColors = () => { onMounted(async () => { await Promise.all([loadApiKeys(), loadSystemConfig()]); + serverClockTimer.value = window.setInterval(() => { + serverClockTickMs.value = performance.now(); + }, 1000); const hash = window.location.hash; if (hash.includes('settings-appearance')) { activeSettingsSection.value = 'appearance'; @@ -1086,6 +1158,10 @@ onMounted(async () => { }); onUnmounted(() => { + if (serverClockTimer.value) { + clearInterval(serverClockTimer.value); + serverClockTimer.value = null; + } if (systemConfigAutoSaveTimer.value) { clearTimeout(systemConfigAutoSaveTimer.value); systemConfigAutoSaveTimer.value = null; @@ -1331,8 +1407,15 @@ onUnmounted(() => { margin-bottom: 0; } -.system-config-group__title { +.system-config-group__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; margin-bottom: 12px; +} + +.system-config-group__title { color: rgb(var(--v-theme-on-surface)); font-size: 1.04rem; font-weight: 760; @@ -1340,6 +1423,26 @@ onUnmounted(() => { line-height: 1.32; } +.timezone-time-preview { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; + font-variant-numeric: tabular-nums; +} + +.timezone-time-preview__item { + padding: 3px 7px; + border: 1px solid rgba(var(--v-theme-primary), 0.16); + border-radius: 6px; + color: rgb(var(--v-theme-primary)); + background: rgba(var(--v-theme-primary), 0.055); + font-size: 0.72rem; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; +} + .system-config-group :deep(.v-card) { margin-bottom: 0 !important; padding-bottom: 0 !important; @@ -1736,6 +1839,15 @@ onUnmounted(() => { max-width: none; } + .system-config-group__heading { + align-items: flex-start; + flex-direction: column; + } + + .timezone-time-preview { + justify-content: flex-start; + } + .system-config-group :deep(.config-row) { padding: 14px 16px; } diff --git a/tests/unit/test_config_profile_service.py b/tests/unit/test_config_profile_service.py new file mode 100644 index 000000000..7b83b8083 --- /dev/null +++ b/tests/unit/test_config_profile_service.py @@ -0,0 +1,25 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from astrbot.dashboard.services.config_service import ConfigProfileService + + +def test_get_system_config_includes_effective_server_time() -> None: + """Verify that the response includes server UTC time and configured offset.""" + fixed_time = datetime(2026, 8, 7, 2, 31, tzinfo=timezone.utc) + service = ConfigProfileService( + SimpleNamespace( + astrbot_config_mgr=SimpleNamespace( + confs={"default": {"timezone": "Asia/Shanghai"}} + ) + ) + ) + + with patch("astrbot.dashboard.services.config_service.datetime") as mock_datetime: + mock_datetime.now.return_value = fixed_time + result = service.get_system_config() + + assert result["server_utc_time"] == "2026-08-07T02:31:00+00:00" + assert result["server_utc_offset_minutes"] == 480 + assert result["config"]["timezone"] == "Asia/Shanghai"