feat(admin): 审核菜单增加未处理数量徽标

This commit is contained in:
linkeyu
2026-07-21 16:04:29 +08:00
parent 3f5356ad87
commit 374ac1fd90
+100 -3
View File
@@ -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<AdminInfo | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [pendingReviewCounts, setPendingReviewCounts] = useState<Partial<Record<ReviewNavKey, number>>>({});
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<WithdrawSummary>('/admin/api/withdraws/summary')
.then(({ data }) => data.reviewing_count)
.catch(() => null)
: Promise.resolve(null),
canAccess('price-reports')
? api.get<PriceReportSummary>('/admin/api/price-reports/summary')
.then(({ data }) => data.pending)
.catch(() => null)
: Promise.resolve(null),
canAccess('feedbacks')
? api.get<FeedbackSummary>('/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 (
<span
className="nav-review-badge"
aria-label={`${count} 条未审核`}
title={`${count} 条未审核`}
>
{count}
</span>
);
};
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)}
</button>
</Tooltip>
))
@@ -183,7 +249,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
onClick={() => router.push(group.key)}
>
<span className="nav-primary-icon">{group.icon}</span>
<span>{group.label}</span>
<span className="nav-item-label">{group.label}</span>
{reviewBadge(group.key)}
</button>
);
}
@@ -202,7 +269,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
onClick={() => router.push(child.key)}
>
<span className="nav-child-icon">{child.icon}</span>
<span>{child.label}</span>
<span className="nav-item-label">{child.label}</span>
{reviewBadge(child.key)}
</button>
))}
</div>
@@ -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;