fix(frontend): harden auth request layer per web guide

- fix double-unwrap bug in useLogin prefs sync (theme sync silently broken)
- request layer: 401 single-flight refresh queue + debounced single redirect
  with return-url, cancel-aware silent passthrough, skipErrorHandler opt-out,
  typed blob download (replace bare fetch export in QueriesPage)
- useFetchList: AbortController + version guard + error state (three states)
- ErrorBoundary with chunk-load auto recovery (reload once)
- permission single source: derive route guard from menu constants, drop
  hand-maintained routePermissionMap; TicketDetailPage uses unified
  hasActionPermission instead of inline role string comparison
- namespaced localStorage keys (opsxsql.*) with read-old-write-new migration
- fix pre-existing tsc errors (InstancePage/WorkflowPage/QueriesPage/
  PlatformConfigPage/RedisDetailPage) - typecheck now passes clean
This commit is contained in:
Logan.Li committed 2026-10-26 17:12:29 +08:00
1 parent 0fcdbc5f6c
commit d5b8b8116c
19 files changed
+502 -110

No files matched your search

+13 -13
View File
@@ -26,6 +26,8 @@ import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDraw
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import type { QueryHistoryItem } from '@/features/query/types';
import { downloadCSV, exportToCSV } from '@/features/query/utils';
import api from '@/api/request';
import { readStorage, writeStorage } from '@/lib/storage';
interface QueryResult {
columns: string[];
@@ -136,13 +138,13 @@ export default function QueriesPage() {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const resultContainerRef = useRef<HTMLDivElement>(null);
// -- Sidebar 拖拽宽度 --
const [sideWidth, setSideWidth] = useState(() => Number(localStorage.getItem('query-side-width')) || 280);
const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280);
const sidebarDragRef = useRef<HTMLDivElement>(null);
// -- 历史 state --
const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
try {
const saved = localStorage.getItem('query_favorites');
const saved = readStorage('queryFavorites');
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
const initialHistory: QueryHistoryItem[] = [];
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
@@ -237,7 +239,7 @@ export default function QueriesPage() {
}, []);
useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
if (/^\d+$/.test(database)) return;
instanceDetailApi.getDbSchema(Number(instanceId), database)
.then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); })
@@ -330,13 +332,11 @@ export default function QueriesPage() {
const handleExportResult = useCallback(async () => {
if (!sql || !instanceId) return;
try {
const resp = await fetch('/api/v1/query/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ instance_id: Number(instanceId), database: database || undefined, sql }),
});
if (!resp.ok) throw new Error('导出失败');
const blob = await resp.blob();
const blob = await api.post<unknown, Blob>('/query/export', {
instance_id: Number(instanceId),
database: database || undefined,
sql,
}, { responseType: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -364,7 +364,7 @@ export default function QueriesPage() {
const toggleFavorite = useCallback((id: number) => {
setHistory(prev => {
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
try { localStorage.setItem('query_favorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ }
try { writeStorage('queryFavorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ }
return next;
});
}, []);
@@ -388,7 +388,7 @@ export default function QueriesPage() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
setSideWidth(w => {
localStorage.setItem('query-side-width', String(w));
writeStorage('querySideWidth', String(w));
return w;
});
};
@@ -634,7 +634,7 @@ export default function QueriesPage() {
<div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
language={editorLang} height="200px" onExecute={handleExecute}
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined} />
schema={(dbType === 'mysql' || dbType === 'postgresql') ? editorSchema : undefined} />
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
<span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}