mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-30 17:33:24 +08:00
feat: show effective AstrBot time in settings (#9581)
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"subtitle": "Стратегия, endpoint и пользовательские шаблоны для рендеринга."
|
||||
}
|
||||
},
|
||||
"timePreview": {
|
||||
"label": "Текущее время AstrBot"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Не удалось загрузить системную конфигурацию",
|
||||
"saveSuccess": "Системная конфигурация сохранена",
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"subtitle": "文本转图像渲染策略、端点和自定义模板。"
|
||||
}
|
||||
},
|
||||
"timePreview": {
|
||||
"label": "AstrBot 当前时间"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "加载系统配置失败",
|
||||
"saveSuccess": "系统配置已保存",
|
||||
|
||||
@@ -64,7 +64,20 @@
|
||||
:class="{ 'system-config-group--with-cleanup': group.key === 'tempStorage' }"
|
||||
@focusout.capture="scheduleSystemConfigAutoSave"
|
||||
>
|
||||
<div class="system-config-group__title">{{ group.title }}</div>
|
||||
<div class="system-config-group__heading">
|
||||
<div class="system-config-group__title">{{ group.title }}</div>
|
||||
<div
|
||||
v-if="group.key === 'runtime' && timezoneTimePreview"
|
||||
class="timezone-time-preview"
|
||||
>
|
||||
<span
|
||||
class="timezone-time-preview__item"
|
||||
>
|
||||
{{ tm('systemConfig.timePreview.label') }} ·
|
||||
{{ timezoneTimePreview.offsetLabel }} {{ timezoneTimePreview.localTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AstrBotConfigV4
|
||||
:metadata="group.metadata"
|
||||
:iterable="systemConfigData"
|
||||
@@ -573,6 +586,11 @@ const configSave2faSaving = ref(false);
|
||||
const configSave2faRotationHint = ref('');
|
||||
const configSavePendingData = ref(null);
|
||||
const systemConfigAutoSaveTimer = ref(null);
|
||||
const serverUtcEpochMs = ref(null);
|
||||
const serverUtcOffsetMinutes = ref(null);
|
||||
const serverClockAnchorMs = ref(null);
|
||||
const serverClockTickMs = ref(null);
|
||||
const serverClockTimer = ref(null);
|
||||
const activeSettingsSection = ref('general');
|
||||
const changelogDialog = ref(false);
|
||||
|
||||
@@ -684,6 +702,34 @@ const systemConfigHasChanges = computed(() => (
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user