feat(datetime): timezone/time_format в настройках + единый хелпер дат
Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
This commit is contained in:
+86
-4
@@ -1,6 +1,6 @@
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const DIRTY_FIELDS = ['systemName', 'timezone', 'timeFormat', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const NOTIFY_IDS = [];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-datetime', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
@@ -14,6 +14,67 @@ let logoOriginal = '';
|
||||
let currentSystemName = '';
|
||||
let photoAiService = null;
|
||||
|
||||
function fillTimezoneOptions() {
|
||||
const sel = document.getElementById('timezone');
|
||||
if (!sel || sel.options.length) return;
|
||||
let zones = [];
|
||||
try { zones = Intl.supportedValuesOf('timeZone'); } catch { zones = []; }
|
||||
if (!zones.length) zones = [DateTime.tz || 'Europe/Moscow'];
|
||||
const browserTz = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; } })();
|
||||
const current = sel.dataset.value || DateTime.tz;
|
||||
sel.innerHTML = zones.map(z => {
|
||||
const off = tzOffsetLabel(z);
|
||||
const label = off ? `${z} (UTC${off})` : z;
|
||||
return `<option value="${esc(z)}">${esc(label)}</option>`;
|
||||
}).join('');
|
||||
const extra = browserTz && !zones.includes(browserTz) ? [browserTz] : [];
|
||||
extra.forEach(z => { sel.insertAdjacentHTML('afterbegin', `<option value="${esc(z)}">${esc(z)}</option>`); });
|
||||
if (current) sel.value = current;
|
||||
}
|
||||
|
||||
function tzOffsetLabel(tz) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('ru-RU', { timeZone: tz, timeZoneName: 'longOffset' }).formatToParts(new Date());
|
||||
const name = parts.find(p => p.type === 'timeZoneName');
|
||||
return name ? String(name.value).replace('GMT', '') : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function previewTz() {
|
||||
const sel = document.getElementById('timezone');
|
||||
return sel && dtValidTz(sel.value) ? sel.value : DateTime.tz;
|
||||
}
|
||||
|
||||
function previewHour12() {
|
||||
const el = document.getElementById('timeFormat');
|
||||
return !!el && el.value === '12h';
|
||||
}
|
||||
|
||||
function renderTimePreview() {
|
||||
const box = document.getElementById('timePreview');
|
||||
if (!box) return;
|
||||
const tz = previewTz();
|
||||
const h12 = previewHour12();
|
||||
const now = new Date();
|
||||
const sample = new Date(now.getTime() + 3661 * 1000);
|
||||
box.innerHTML = `<span class="chip">Сейчас: ${esc(fmtIn(sample, tz, h12))}</span>`
|
||||
+ `<span class="chip-sub">${esc(fmtIn(now, tz, h12))}</span>`
|
||||
+ `<span class="chip-sub">Расписание 18:30 → ${esc(fmtHmIn('18:30:00', h12))}</span>`
|
||||
+ `<span class="chip-sub">Отчёт за занятие 09:05 → ${esc(fmtHmIn('09:05:00', h12))}</span>`;
|
||||
}
|
||||
|
||||
function fmtIn(date, tz, h12) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { timeZone: tz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: h12 }).format(date);
|
||||
}
|
||||
|
||||
function fmtHmIn(v, h12) {
|
||||
const saved = DateTime.hour12;
|
||||
DateTime.hour12 = h12;
|
||||
const out = fmtHmStr(v);
|
||||
DateTime.hour12 = saved;
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderLogoPreview() {
|
||||
const img = document.getElementById('logoPreviewImg');
|
||||
const fb = document.getElementById('logoPreviewFallback');
|
||||
@@ -89,6 +150,15 @@ function humanizeMinutes(min) {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function bindTimePreview() {
|
||||
['timezone', 'timeFormat'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', renderTimePreview);
|
||||
el.addEventListener('input', renderTimePreview);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -123,6 +193,7 @@ function revertChanges() {
|
||||
renderLogoPreview();
|
||||
clearFieldError('spamInterval');
|
||||
updateSpamUi();
|
||||
renderTimePreview();
|
||||
updateDirty();
|
||||
showToast('Изменения отменены');
|
||||
}
|
||||
@@ -189,6 +260,7 @@ async function renderNotifyTypes(s) {
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
fillTimezoneOptions();
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
@@ -202,6 +274,11 @@ async function loadSettings() {
|
||||
renderLogoPreview();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
|
||||
const tzSel = document.getElementById('timezone');
|
||||
const storedTz = String(s.timezone || '');
|
||||
tzSel.value = Array.from(tzSel.options).some(o => o.value === storedTz) ? storedTz : DateTime.tz;
|
||||
document.getElementById('timeFormat').value = ['24h', '12h'].includes(s.time_format) ? s.time_format : '24h';
|
||||
renderTimePreview();
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||
@@ -272,7 +349,7 @@ async function loadSystemInfo(btn) {
|
||||
const data = await res.json();
|
||||
renderSystemInfo(data);
|
||||
renderStackInfo(data.stack);
|
||||
const stamp = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
const stamp = 'обновлено ' + fmtTimeOnly(new Date());
|
||||
const live = document.getElementById('systemLive');
|
||||
if (live) live.textContent = stamp;
|
||||
const stackLive = document.getElementById('stackLive');
|
||||
@@ -804,6 +881,8 @@ async function saveSettings() {
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
system_name: document.getElementById('systemName').value.trim(),
|
||||
timezone: document.getElementById('timezone').value,
|
||||
time_format: document.getElementById('timeFormat').value,
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
@@ -828,6 +907,8 @@ async function saveSettings() {
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
dtApply({ timezone: document.getElementById('timezone').value, time_format: document.getElementById('timeFormat').value });
|
||||
renderTimePreview();
|
||||
showToast('Настройки сохранены');
|
||||
logoOriginal = currentLogo;
|
||||
snapshotDirty();
|
||||
@@ -939,7 +1020,7 @@ async function loadBans() {
|
||||
el.innerHTML = bans.map(b => `
|
||||
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
||||
<b>${esc(b.ip)}</b>
|
||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
|
||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${fmtFull(b.banned_until)}</span>
|
||||
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
||||
</span>`).join('');
|
||||
}
|
||||
@@ -1091,6 +1172,7 @@ window.addEventListener('beforeunload', e => {
|
||||
try {
|
||||
await loadSettings();
|
||||
bindDirty();
|
||||
bindTimePreview();
|
||||
if (authStatus) authStatus.classList.add('loaded');
|
||||
} catch {
|
||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||
|
||||
Reference in New Issue
Block a user