feat: 优化web
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 '?';
|
||||
}
|
||||
Reference in New Issue
Block a user