feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
type UseApiState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
/**
* 通用数据获取 hook,封装 loading / error / data 状态 + AbortController 取消。
*
* @param fetcher 返回 Promise 的数据获取函数,接收 AbortSignal
* @param deps 依赖数组,变化时重新请求
*/
export function useApi<T>(
fetcher: (signal?: AbortSignal) => Promise<T>,
deps: unknown[] = [],
): UseApiState<T> & { refetch: () => void } {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: true,
error: null,
});
const abortRef = useRef<AbortController | null>(null);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const load = useCallback(() => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setState((s) => ({ ...s, loading: true, error: null }));
fetcherRef
.current(ac.signal)
.then((data) => {
if (!ac.signal.aborted) {
setState({ data, loading: false, error: null });
}
})
.catch((e: unknown) => {
if (!ac.signal.aborted) {
setState({ data: null, loading: false, error: e instanceof Error ? e.message : String(e) });
}
});
}, deps);
useEffect(() => {
load();
return () => {
abortRef.current?.abort();
};
}, [load]);
return { ...state, refetch: load };
}
+129
View File
@@ -0,0 +1,129 @@
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { MenuNode } from '@/lib/api/types/menu';
const HOVER_LEAVE_MS = 280;
type FlyoutState = {
flyoutRoot: MenuNode | null;
l1AnchorRect: DOMRect | null;
openFlyout: (node: MenuNode, anchorEl: HTMLDivElement) => void;
scheduleCloseFlyout: () => void;
clearCloseTimer: () => void;
closeFlyoutNow: () => void;
toggleFlyoutClick: (node: MenuNode, wrapper: HTMLDivElement | null) => void;
l1AnchorElRef: React.RefObject<HTMLDivElement | null>;
railScrollRef: React.RefObject<HTMLDivElement | null>;
};
/** 从 ClassicCollapsedSidebar 提取的 flyout 状态管理逻辑 */
export function useFlyoutState(pathname: string): FlyoutState {
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
const [l1AnchorRect, setL1AnchorRect] = useState<DOMRect | null>(null);
const l1AnchorElRef = useRef<HTMLDivElement | null>(null);
const railScrollRef = useRef<HTMLDivElement | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeFlyout = useCallback(() => {
setFlyoutRoot(null);
setL1AnchorRect(null);
l1AnchorElRef.current = null;
}, []);
const scheduleCloseFlyout = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = setTimeout(() => {
closeFlyout();
closeTimerRef.current = null;
}, HOVER_LEAVE_MS);
}, [clearCloseTimer, closeFlyout]);
const closeFlyoutNow = useCallback(() => {
clearCloseTimer();
closeFlyout();
}, [clearCloseTimer, closeFlyout]);
const syncL1Anchor = useCallback(() => {
const el = l1AnchorElRef.current;
if (el) setL1AnchorRect(el.getBoundingClientRect());
}, []);
useLayoutEffect(() => {
if (flyoutRoot) syncL1Anchor();
}, [flyoutRoot, syncL1Anchor]);
useEffect(() => {
if (!flyoutRoot) return;
const onResize = () => syncL1Anchor();
window.addEventListener('resize', onResize);
const rs = railScrollRef.current;
rs?.addEventListener('scroll', onResize, { passive: true });
return () => {
window.removeEventListener('resize', onResize);
rs?.removeEventListener('scroll', onResize);
};
}, [flyoutRoot, syncL1Anchor]);
useEffect(() => {
closeFlyout();
}, [pathname, closeFlyout]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeFlyout();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [closeFlyout]);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
const openFlyout = useCallback(
(node: MenuNode, anchorEl: HTMLDivElement) => {
clearCloseTimer();
l1AnchorElRef.current = anchorEl;
setL1AnchorRect(anchorEl.getBoundingClientRect());
setFlyoutRoot(node);
},
[clearCloseTimer],
);
const toggleFlyoutClick = useCallback(
(node: MenuNode, wrapper: HTMLDivElement | null) => {
setFlyoutRoot((prev) => {
if (prev?.id === node.id) {
setL1AnchorRect(null);
l1AnchorElRef.current = null;
return null;
}
if (wrapper) {
l1AnchorElRef.current = wrapper;
setL1AnchorRect(wrapper.getBoundingClientRect());
return node;
}
return prev;
});
},
[],
);
return {
flyoutRoot,
l1AnchorRect,
openFlyout,
scheduleCloseFlyout,
clearCloseTimer,
closeFlyoutNow,
toggleFlyoutClick,
l1AnchorElRef,
railScrollRef,
};
}
+14
View File
@@ -0,0 +1,14 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTabStore } from '@/stores/tab-store';
/** 站内导航 + 多标签联动,替代 onMenuNavigate prop drilling */
export function useMenuNavigation() {
const router = useRouter();
return (path: string, title: string) => {
useTabStore.getState().openOrActivate({ path, title });
router.push(path);
};
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { iamMenu } from '@/lib/api/iam';
import type { MenuNode } from '@/lib/api/types/menu';
import { useAuthStore } from '@/stores/auth-store';
import { useLayoutStore } from '@/stores/layout-store';
import { useTenantStore } from '@/stores/tenant-store';
type NavMenuState = {
items: MenuNode[];
loading: boolean;
error: string | null;
authed: boolean;
permissions: string[];
};
/** 从 AppChrome 提取的导航菜单数据获取逻辑 */
export function useNavMenu(): NavMenuState {
const accessToken = useAuthStore((s) => s.accessToken);
const authed = Boolean(accessToken);
const tenantId = useTenantStore((s) => s.tenantId);
const setPerms = useLayoutStore((s) => s.setPerms);
const [items, setItems] = useState<MenuNode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
useEffect(() => {
if (!accessToken) {
setItems([]);
setError(null);
setPerms([]);
setPermissions([]);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([
iamMenu.nav().catch(() => [] as MenuNode[]),
iamMenu.perms().catch(() => ({ perms: [] as string[] })),
])
.then(([tree, pr]) => {
if (cancelled) return;
const navItems = Array.isArray(tree) ? tree : [];
const perms = Array.isArray(pr?.perms) ? pr.perms : [];
setItems(navItems);
setPerms(perms);
setPermissions(perms);
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
setItems([]);
setPerms([]);
setPermissions([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [accessToken, tenantId, setPerms]);
return { items, loading, error, authed, permissions };
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { introspectAccessToken } from '@/lib/api/auth';
import { iamUser } from '@/lib/api/iam';
import type { IamUser } from '@/lib/api/types/user';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantStore } from '@/stores/tenant-store';
type UserProfileState = {
profile: IamUser | null;
userSub: string | null;
loading: boolean;
label: string;
};
function displayLabel(profile: IamUser | null, userSub: string | null, loading: boolean): string {
if (loading) return '加载中…';
if (profile) {
const n = profile.real_name?.trim() || profile.user_name?.trim();
if (n) return n;
}
if (userSub) return userSub.length > 12 ? `${userSub.slice(0, 10)}` : userSub;
return '用户';
}
/** 从 UserMenu 提取的用户资料获取逻辑 */
export function useUserProfile(): UserProfileState {
const accessToken = useAuthStore((s) => s.accessToken);
const [profile, setProfile] = useState<IamUser | null>(null);
const [userSub, setUserSub] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!accessToken) {
setProfile(null);
setUserSub(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setProfile(null);
setUserSub(null);
(async () => {
try {
const intro = await introspectAccessToken(accessToken);
if (cancelled) return;
if (!intro.active || !intro.sub) {
setUserSub(null);
setLoading(false);
return;
}
setUserSub(intro.sub);
try {
const u = await iamUser.get(intro.sub);
if (!cancelled) {
setProfile(u);
useTenantStore.getState().hydrateFromUserTenant(u.tenant_id);
}
} catch {
if (!cancelled) setProfile(null);
}
} catch {
if (!cancelled) {
setUserSub(null);
setProfile(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [accessToken]);
return {
profile,
userSub,
loading,
label: displayLabel(profile, userSub, loading),
};
}
export function avatarInitials(profile: IamUser | null, userSub: string | null): string {
const name = profile?.real_name?.trim() || profile?.user_name?.trim();
if (name) {
const arr = [...name];
if (arr.length >= 2) return (arr[0] + arr[1]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
if (userSub) return userSub.replace(/-/g, '').slice(0, 2).toUpperCase() || '?';
return '?';
}