feat: Enhance error handling in server responses by including request IDs and detailed error codes; improve frontend modal components for better accessibility and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m59s

This commit is contained in:
2025-08-11 21:32:32 +07:00
parent f44acbff98
commit 3b1a59b03f
6 changed files with 137 additions and 77 deletions
+11 -2
View File
@@ -9,9 +9,18 @@ export default function ConfirmDialog({ open, title, message, confirmText = 'П
}, [open])
if (!open) return null
return (
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}>
<div className="modal-dialog" role="document">
<div className="modal-content" ref={ref}>
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') {
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 id="confirm-title" className="modal-title">{title || 'Подтверждение'}</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onCancel}></button>
+13 -3
View File
@@ -4,11 +4,21 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
const removed = diff?.removed?.length || 0;
const changed = diff?.changed?.length || 0;
return (
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-diff-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 className="modal-title">Подтвердить сохранение</h5>
<h5 id="confirm-diff-title" className="modal-title">Подтвердить сохранение</h5>
<button type="button" className="btn-close" onClick={onClose}></button>
</div>
<div className="modal-body">
+43 -15
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import api from '../lib/api.js';
import { useQuery } from '@tanstack/react-query';
import ConfirmDialog from './ConfirmDialog.jsx';
import { useNotify } from './NotifyProvider.jsx';
import { IconHistory, IconRefresh, IconDeviceFloppy } from '@tabler/icons-react';
@@ -22,30 +23,49 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
useEffect(() => { if (show) refetch().catch(() => notify.error('Не удалось загрузить историю версий')); }, [show, resource]);
const [confirmState, setConfirmState] = useState({ open: false, onConfirm: null });
const rollback = async (versionId) => {
if (!versionId) return;
if (!confirm('Откатить к выбранной версии? Текущее содержимое будет перезаписано.')) return;
setLoading(true);
try {
const res = await api.post(`/history/${resource}/rollback`, { versionId });
notify.success('Откат выполнен');
onRolledBack && onRolledBack(res.data || {});
onClose && onClose();
} catch (e) {
notify.error('Не удалось выполнить откат');
} finally {
setLoading(false);
}
await new Promise((resolve) => {
setConfirmState({
open: true,
onConfirm: async () => {
setConfirmState({ open: false, onConfirm: null });
setLoading(true);
try {
const res = await api.post(`/history/${resource}/rollback`, { versionId });
notify.success('Откат выполнен');
onRolledBack && onRolledBack(res.data || {});
onClose && onClose();
} catch (e) {
notify.error('Не удалось выполнить откат');
} finally {
setLoading(false);
}
resolve();
}
});
});
};
if (!show) return null;
return (
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="history-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
<h5 id="history-title" className="modal-title d-flex align-items-center">
<IconHistory className="me-2" /> История версий
</h5>
<button type="button" className="btn-close" onClick={onClose}></button>
@@ -96,6 +116,14 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack })
</div>
</div>
</div>
<ConfirmDialog
open={confirmState.open}
title="Подтверждение"
message="Откатить к выбранной версии? Текущее содержимое будет перезаписано."
confirmText="Откатить"
onCancel={() => setConfirmState({ open: false, onConfirm: null })}
onConfirm={confirmState.onConfirm}
/>
</div>
);
}
+13 -7
View File
@@ -37,18 +37,18 @@ export function NotifyProvider({ children }) {
timeouts.current.set(id, t);
}, [remove]);
const add = useCallback((type, message) => {
const add = useCallback((type, message, details) => {
const text = String(message || '').trim();
if (!text) return;
setItems((prev) => {
const dup = prev.find((n) => n.type === type && n.message === text);
if (dup) {
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now() } : n);
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now(), details: details ?? n.details } : n);
schedule(dup.id, type);
return updated;
}
const id = idSeq.current++;
const next = [...prev, { id, type, message: text, count: 1, createdAt: Date.now() }];
const next = [...prev, { id, type, message: text, details, count: 1, createdAt: Date.now() }];
schedule(id, type);
return next;
});
@@ -62,10 +62,10 @@ export function NotifyProvider({ children }) {
const api = useMemo(() => ({
add,
success: (m) => add('success', m),
error: (m) => add('error', m),
info: (m) => add('info', m),
warning: (m) => add('warning', m),
success: (m, d) => add('success', m, d),
error: (m, d) => add('error', m, d),
info: (m, d) => add('info', m, d),
warning: (m, d) => add('warning', m, d),
remove,
clear,
}), [add, remove, clear]);
@@ -116,6 +116,12 @@ function NotifyViewport({ items, onClose }) {
<div className="me-1 mt-1">{iconByType(n.type)}</div>
<div className="flex-grow-1">
{n.message}
{n.details && (
<details className="small mt-1">
<summary>Подробнее</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}</pre>
</details>
)}
{n.count > 1 && (
<span className="badge bg-white text-body border ms-2">×{n.count}</span>
)}
+6 -2
View File
@@ -34,9 +34,13 @@ api.interceptors.response.use(
(err) => {
try {
const status = err?.response?.status;
const message = err?.response?.data?.message || err?.message || 'Ошибка запроса';
const data = err?.response?.data || {};
const message = data?.message || err?.message || 'Ошибка запроса';
const code = data?.code;
const details = data?.details;
const requestId = data?.requestId || err?.response?.headers?.['x-request-id'] || err?.config?.headers?.['X-Request-Id'];
if (status >= 400 && typeof window !== 'undefined' && window.notify?.error) {
window.notify.error(`${message} (${status ?? ''})`);
window.notify.add('error', `${message}${status ? ` (${status})` : ''}${requestId ? ` • reqId=${requestId}` : ''}`, details ? { code, requestId, details } : undefined);
}
} catch {}
return Promise.reject(err);