From 374ac1fd9047cf45290d81552d87ee7935dcf111 Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Tue, 21 Jul 2026 16:04:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AE=A1=E6=A0=B8=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=A2=9E=E5=8A=A0=E6=9C=AA=E5=A4=84=E7=90=86=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E5=BE=BD=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/layout.tsx | 103 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 8d88cae..7c81eb3 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -25,7 +25,12 @@ import { import { Avatar, Dropdown, Layout, Tooltip } from 'antd'; import { api } from '@/lib/api'; import { clearAuth, getAdmin, getToken, setAuth } from '@/lib/auth'; -import type { AdminInfo } from '@/lib/types'; +import type { + AdminInfo, + FeedbackSummary, + PriceReportSummary, + WithdrawSummary, +} from '@/lib/types'; const { Sider, Header, Content } = Layout; @@ -35,6 +40,8 @@ type NavItem = { label: string; }; +type ReviewNavKey = '/withdraws' | '/price-reports' | '/feedbacks'; + type NavGroup = | (NavItem & { children?: never }) | { @@ -96,6 +103,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) const pathname = usePathname(); const [admin, setAdmin] = useState(null); const [collapsed, setCollapsed] = useState(false); + const [pendingReviewCounts, setPendingReviewCounts] = useState>>({}); useEffect(() => { const token = getToken(); @@ -114,6 +122,49 @@ export default function MainLayout({ children }: { children: React.ReactNode }) .catch(() => {}); }, [router]); + useEffect(() => { + if (!admin) return; + let cancelled = false; + const canAccess = (page: string) => + admin.role === 'super_admin' || !admin.pages || admin.pages.includes(page); + + const refresh = async () => { + const [withdraws, priceReports, feedbacks] = await Promise.all([ + canAccess('withdraws') + ? api.get('/admin/api/withdraws/summary') + .then(({ data }) => data.reviewing_count) + .catch(() => null) + : Promise.resolve(null), + canAccess('price-reports') + ? api.get('/admin/api/price-reports/summary') + .then(({ data }) => data.pending) + .catch(() => null) + : Promise.resolve(null), + canAccess('feedbacks') + ? api.get('/admin/api/feedbacks/summary') + .then(({ data }) => data.pending) + .catch(() => null) + : Promise.resolve(null), + ]); + if (cancelled) return; + setPendingReviewCounts((current) => ({ + ...current, + ...(withdraws == null ? {} : { '/withdraws': withdraws }), + ...(priceReports == null ? {} : { '/price-reports': priceReports }), + ...(feedbacks == null ? {} : { '/feedbacks': feedbacks }), + })); + }; + + void refresh(); + const timer = window.setInterval(refresh, 30_000); + window.addEventListener('focus', refresh); + return () => { + cancelled = true; + window.clearInterval(timer); + window.removeEventListener('focus', refresh); + }; + }, [admin, pathname]); + if (!admin) return null; // 守卫期间不闪烁内容 // 选中态:取路径一级(/users/123 -> /users) @@ -132,6 +183,20 @@ export default function MainLayout({ children }: { children: React.ReactNode }) hasChildren(group) ? group.children : [group], ); + const reviewBadge = (key: string) => { + const count = pendingReviewCounts[key as ReviewNavKey] ?? 0; + if (count <= 0) return null; + return ( + + {count} + + ); + }; + const logout = () => { clearAuth(); router.replace('/login'); @@ -166,6 +231,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) aria-label={item.label} > {item.icon} + {reviewBadge(item.key)} )) @@ -183,7 +249,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) onClick={() => router.push(group.key)} > {group.icon} - {group.label} + {group.label} + {reviewBadge(group.key)} ); } @@ -202,7 +269,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) onClick={() => router.push(child.key)} > {child.icon} - {child.label} + {child.label} + {reviewBadge(child.key)} ))} @@ -269,6 +337,29 @@ export default function MainLayout({ children }: { children: React.ReactNode }) color: rgba(255, 255, 255, 0.72); font-size: 16px; } + .nav-item-label { + min-width: 0; + flex: 1; + } + .nav-review-badge { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex: 0 0 auto; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ff4d4f; + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08); + } .nav-direct, .nav-child, .nav-icon-button { @@ -332,6 +423,7 @@ export default function MainLayout({ children }: { children: React.ReactNode }) padding: 0 8px 14px; } .nav-icon-button { + position: relative; display: inline-flex; align-items: center; justify-content: center; @@ -342,6 +434,11 @@ export default function MainLayout({ children }: { children: React.ReactNode }) color: rgba(255, 255, 255, 0.72); font-size: 17px; } + .nav-icon-button .nav-review-badge { + position: absolute; + top: 1px; + right: -6px; + } .nav-icon-button:hover, .nav-icon-button.is-selected { background: #1677ff;