c2f49f2658
## 变更内容 - 提现审核菜单展示待审核 reviewing_count - 低价审核菜单展示 pending 数量 - 用户反馈菜单展示 pending 数量(包含历史 new 状态) - 数量为 0 时不显示徽标 - 单数字保持红色圆形,多位数按内容自动扩展为胶囊 - 侧栏收起时徽标显示在图标右上角 ## 刷新与容错 - 三个汇总接口并行请求,单个失败不影响其他徽标 - 页面切换、窗口重新聚焦时刷新 - 每 30 秒自动刷新 - 仅请求当前账号有权限查看的审核页面 ## 验证 - npx tsc --noEmit - npm run build - 本地实际验证 2、12、1234 三种数字长度均完整显示 --------- Co-authored-by: linkeyu <798648091@qq.com> Reviewed-on: #57 Co-authored-by: linkeyu <linkeyu@wonderable.ai> Co-committed-by: linkeyu <linkeyu@wonderable.ai>
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { usePathname, useRouter } from 'next/navigation';
|
||
import {
|
||
BarChartOutlined,
|
||
DashboardOutlined,
|
||
DatabaseOutlined,
|
||
FileSearchOutlined,
|
||
FlagOutlined,
|
||
GiftOutlined,
|
||
HeartOutlined,
|
||
LineChartOutlined,
|
||
LogoutOutlined,
|
||
MessageOutlined,
|
||
MoneyCollectOutlined,
|
||
NotificationOutlined,
|
||
ProfileOutlined,
|
||
SafetyCertificateOutlined,
|
||
SettingOutlined,
|
||
ShareAltOutlined,
|
||
TeamOutlined,
|
||
UserOutlined,
|
||
} from '@ant-design/icons';
|
||
import { Avatar, Dropdown, Layout, Tooltip } from 'antd';
|
||
import { api } from '@/lib/api';
|
||
import { clearAuth, getAdmin, getToken, setAuth } from '@/lib/auth';
|
||
import {
|
||
REVIEW_BADGE_REFRESH_EVENT,
|
||
type ReviewBadgeKey,
|
||
} from '@/lib/reviewBadge';
|
||
import type {
|
||
AdminInfo,
|
||
FeedbackSummary,
|
||
PriceReportSummary,
|
||
WithdrawSummary,
|
||
} from '@/lib/types';
|
||
|
||
const { Sider, Header, Content } = Layout;
|
||
|
||
type NavItem = {
|
||
key: string;
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
};
|
||
|
||
type NavGroup =
|
||
| (NavItem & { children?: never })
|
||
| {
|
||
key: string;
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
children: NavItem[];
|
||
};
|
||
|
||
const hasChildren = (group: NavGroup): group is NavGroup & { children: NavItem[] } =>
|
||
Array.isArray(group.children);
|
||
|
||
const NAV_GROUPS: NavGroup[] = [
|
||
{
|
||
key: 'dashboard',
|
||
icon: <DashboardOutlined />,
|
||
label: '看板',
|
||
children: [
|
||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
|
||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券记录' },
|
||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
|
||
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
|
||
],
|
||
},
|
||
{
|
||
key: 'reward-review',
|
||
icon: <MoneyCollectOutlined />,
|
||
label: '奖励审核',
|
||
children: [
|
||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现审核' },
|
||
{ key: '/price-reports', icon: <FlagOutlined />, label: '低价审核' },
|
||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '用户反馈' },
|
||
],
|
||
},
|
||
{
|
||
key: 'data-config',
|
||
icon: <SettingOutlined />,
|
||
label: '数据配置',
|
||
children: [
|
||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
|
||
{ key: '/huawei-review', icon: <SafetyCertificateOutlined />, label: '华为审核开关' },
|
||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||
],
|
||
},
|
||
{ key: '/admins', icon: <TeamOutlined />, label: '权限管理' },
|
||
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
|
||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||
];
|
||
|
||
// 导航项 key(/dashboard)→ 权限页 key(dashboard),与后端 permissions.py 目录对齐
|
||
const permOf = (key: string) => key.replace(/^\//, '');
|
||
|
||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
const [pendingReviewCounts, setPendingReviewCounts] = useState<Partial<Record<ReviewBadgeKey, number>>>({});
|
||
const reviewRefreshVersion = useRef<Partial<Record<ReviewBadgeKey, number>>>({});
|
||
|
||
useEffect(() => {
|
||
const token = getToken();
|
||
if (!token) {
|
||
router.replace('/login');
|
||
return;
|
||
}
|
||
setAdmin(getAdmin()); // 先用本地存的(快、不闪)
|
||
// 再拉 /me 刷新有效可见页:超管改过该角色权限也能及时反映到左侧导航
|
||
api
|
||
.get<AdminInfo>('/admin/api/auth/me')
|
||
.then(({ data }) => {
|
||
setAdmin(data);
|
||
setAuth(token, data);
|
||
})
|
||
.catch(() => {});
|
||
}, [router]);
|
||
|
||
useEffect(() => {
|
||
if (!admin) return;
|
||
const canAccess = (page: string) =>
|
||
admin.role === 'super_admin' || !admin.pages || admin.pages.includes(page);
|
||
|
||
const refresh = async (key: ReviewBadgeKey) => {
|
||
if (!canAccess(key.slice(1))) return;
|
||
const version = (reviewRefreshVersion.current[key] ?? 0) + 1;
|
||
reviewRefreshVersion.current[key] = version;
|
||
let count: number;
|
||
if (key === '/withdraws') {
|
||
count = (await api.get<WithdrawSummary>('/admin/api/withdraws/summary')).data.reviewing_count;
|
||
} else if (key === '/price-reports') {
|
||
count = (await api.get<PriceReportSummary>('/admin/api/price-reports/summary')).data.pending;
|
||
} else {
|
||
count = (await api.get<FeedbackSummary>('/admin/api/feedbacks/summary')).data.pending;
|
||
}
|
||
if (reviewRefreshVersion.current[key] !== version) return;
|
||
setPendingReviewCounts((current) => ({ ...current, [key]: count }));
|
||
};
|
||
|
||
const refreshSafely = (key: ReviewBadgeKey) => refresh(key).catch(() => {});
|
||
void Promise.all([
|
||
refreshSafely('/withdraws'),
|
||
refreshSafely('/price-reports'),
|
||
refreshSafely('/feedbacks'),
|
||
]);
|
||
|
||
const onReviewCompleted = (event: Event) => {
|
||
const key = (event as CustomEvent<ReviewBadgeKey>).detail;
|
||
if (key) void refreshSafely(key);
|
||
};
|
||
window.addEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
|
||
return () => {
|
||
window.removeEventListener(REVIEW_BADGE_REFRESH_EVENT, onReviewCompleted);
|
||
};
|
||
}, [admin]);
|
||
|
||
if (!admin) return null; // 守卫期间不闪烁内容
|
||
|
||
// 选中态:取路径一级(/users/123 -> /users)
|
||
const selectedKey = '/' + (pathname.split('/')[1] || 'dashboard');
|
||
// 叶子导航项按当前角色的有效可见页过滤:super_admin 恒可见;缺 pages 信息(旧登录)兜底全显示不锁死。
|
||
const isSuper = admin.role === 'super_admin';
|
||
const canShowLeaf = (item: { key: string }) =>
|
||
isSuper || !admin.pages || admin.pages.includes(permOf(item.key));
|
||
const visibleGroups = NAV_GROUPS
|
||
.map((group) => {
|
||
if (!hasChildren(group)) return group;
|
||
return { ...group, children: group.children.filter(canShowLeaf) };
|
||
})
|
||
.filter((group) => (hasChildren(group) ? group.children.length > 0 : canShowLeaf(group)));
|
||
const flatNavItems = visibleGroups.flatMap((group) =>
|
||
hasChildren(group) ? group.children : [group],
|
||
);
|
||
|
||
const reviewBadge = (key: string) => {
|
||
const count = pendingReviewCounts[key as ReviewBadgeKey] ?? 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');
|
||
};
|
||
|
||
return (
|
||
<Layout style={{ minHeight: '100vh' }}>
|
||
<Sider
|
||
theme="dark"
|
||
breakpoint="lg"
|
||
collapsible
|
||
collapsed={collapsed}
|
||
collapsedWidth={72}
|
||
width={220}
|
||
onCollapse={setCollapsed}
|
||
style={{
|
||
position: 'sticky',
|
||
top: 0,
|
||
height: '100vh',
|
||
overflowY: 'auto',
|
||
overflowX: 'hidden',
|
||
}}
|
||
>
|
||
<nav className={`side-nav${collapsed ? ' side-nav-collapsed' : ''}`} aria-label="后台导航">
|
||
{collapsed
|
||
? flatNavItems.map((item) => (
|
||
<Tooltip key={item.key} title={item.label} placement="right">
|
||
<button
|
||
type="button"
|
||
className={`nav-icon-button${selectedKey === item.key ? ' is-selected' : ''}`}
|
||
onClick={() => router.push(item.key)}
|
||
aria-label={item.label}
|
||
>
|
||
{item.icon}
|
||
{reviewBadge(item.key)}
|
||
</button>
|
||
</Tooltip>
|
||
))
|
||
: visibleGroups.map((group) => {
|
||
const isGroup = hasChildren(group);
|
||
const groupSelected = isGroup
|
||
? group.children.some((child) => child.key === selectedKey)
|
||
: selectedKey === group.key;
|
||
if (!isGroup) {
|
||
return (
|
||
<button
|
||
key={group.key}
|
||
type="button"
|
||
className={`nav-primary nav-direct${groupSelected ? ' is-selected' : ''}`}
|
||
onClick={() => router.push(group.key)}
|
||
>
|
||
<span className="nav-primary-icon">{group.icon}</span>
|
||
<span className="nav-item-label">{group.label}</span>
|
||
{reviewBadge(group.key)}
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<section key={group.key} className={`nav-group${groupSelected ? ' is-active' : ''}`}>
|
||
<div className="nav-primary">
|
||
<span className="nav-primary-icon">{group.icon}</span>
|
||
<span>{group.label}</span>
|
||
</div>
|
||
<div className="nav-children">
|
||
{group.children.map((child) => (
|
||
<button
|
||
key={child.key}
|
||
type="button"
|
||
className={`nav-child${selectedKey === child.key ? ' is-selected' : ''}`}
|
||
onClick={() => router.push(child.key)}
|
||
>
|
||
<span className="nav-child-icon">{child.icon}</span>
|
||
<span className="nav-item-label">{child.label}</span>
|
||
{reviewBadge(child.key)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
</nav>
|
||
</Sider>
|
||
<Layout>
|
||
<Header
|
||
style={{
|
||
background: '#fff',
|
||
padding: '0 24px',
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<Dropdown
|
||
menu={{
|
||
items: [
|
||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: logout },
|
||
],
|
||
}}
|
||
>
|
||
<span style={{ cursor: 'pointer' }}>
|
||
<Avatar size="small" icon={<UserOutlined />} /> {admin.username}({admin.role})
|
||
</span>
|
||
</Dropdown>
|
||
</Header>
|
||
<Content style={{ margin: 24 }}>{children}</Content>
|
||
</Layout>
|
||
<style jsx global>{`
|
||
.side-nav {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 0 10px 14px;
|
||
}
|
||
.nav-group {
|
||
padding: 6px 0 8px;
|
||
}
|
||
.nav-group + .nav-group,
|
||
.nav-group + .nav-direct,
|
||
.nav-direct + .nav-direct {
|
||
margin-top: 4px;
|
||
}
|
||
.nav-primary {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-height: 36px;
|
||
padding: 0 12px;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
letter-spacing: 0;
|
||
}
|
||
.nav-primary-icon,
|
||
.nav-child-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
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 {
|
||
border: 0;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
text-align: left;
|
||
}
|
||
.nav-direct {
|
||
width: 100%;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
}
|
||
.nav-direct:hover,
|
||
.nav-direct.is-selected {
|
||
background: #1677ff;
|
||
color: #fff;
|
||
}
|
||
.nav-direct:hover .nav-primary-icon,
|
||
.nav-direct.is-selected .nav-primary-icon {
|
||
color: #fff;
|
||
}
|
||
.nav-children {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
margin-left: 34px;
|
||
margin-top: -2px;
|
||
padding-right: 2px;
|
||
}
|
||
.nav-child {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
min-height: 34px;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: rgba(255, 255, 255, 0.72);
|
||
font-size: 13px;
|
||
padding: 0 10px;
|
||
}
|
||
.nav-child:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: #fff;
|
||
}
|
||
.nav-child.is-selected {
|
||
background: #1677ff;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
.nav-child.is-selected .nav-child-icon {
|
||
color: #fff;
|
||
}
|
||
.nav-group.is-active .nav-primary {
|
||
color: #fff;
|
||
}
|
||
.side-nav-collapsed {
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 8px 14px;
|
||
}
|
||
.nav-icon-button {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
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;
|
||
color: #fff;
|
||
}
|
||
`}</style>
|
||
</Layout>
|
||
);
|
||
}
|