3a2aa584a3
- 页面 analytics-health/page.tsx:总览卡(埋点/上报两大率+四原子量)、按天双折线趋势 (@ant-design/plots v2,dynamic ssr:false)、event/app_ver/oem 下钻表;含刷新+ 「数据更新于」时间戳、近7/30天区间预设、率<90%标红、四指标 tooltip。 - 类型 types.ts:HealthMetrics / HealthTrendPoint / HealthBreakdownRow。 - 导航 layout.tsx:「看板」组挂 /analytics-health(仅 super_admin 可见,后端权限零改动)。 - 数据来自后端 feat/analytics-success-rate 分支的 /admin/api/analytics-health/* 三个只读端点; 区间按北京日转 UTC 右开区间传参。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { usePathname, useRouter } from 'next/navigation';
|
||
import {
|
||
BarChartOutlined,
|
||
DashboardOutlined,
|
||
DatabaseOutlined,
|
||
FileSearchOutlined,
|
||
FlagOutlined,
|
||
GiftOutlined,
|
||
HeartOutlined,
|
||
LineChartOutlined,
|
||
LogoutOutlined,
|
||
MessageOutlined,
|
||
MoneyCollectOutlined,
|
||
NotificationOutlined,
|
||
ProfileOutlined,
|
||
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 type { AdminInfo } 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: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' },
|
||
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
|
||
{ key: '/comparison-records', icon: <ProfileOutlined />, 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: '/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);
|
||
|
||
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]);
|
||
|
||
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 logout = () => {
|
||
clearAuth();
|
||
router.replace('/login');
|
||
};
|
||
|
||
return (
|
||
<Layout style={{ minHeight: '100vh' }}>
|
||
<Sider
|
||
theme="dark"
|
||
breakpoint="lg"
|
||
collapsible
|
||
collapsed={collapsed}
|
||
collapsedWidth={72}
|
||
width={220}
|
||
onCollapse={setCollapsed}
|
||
>
|
||
<div
|
||
style={{
|
||
height: 48,
|
||
margin: 16,
|
||
color: '#fff',
|
||
fontWeight: 600,
|
||
textAlign: 'center',
|
||
lineHeight: '48px',
|
||
}}
|
||
>
|
||
运营后台
|
||
</div>
|
||
<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}
|
||
</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>{group.label}</span>
|
||
</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>{child.label}</span>
|
||
</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-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 {
|
||
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:hover,
|
||
.nav-icon-button.is-selected {
|
||
background: #1677ff;
|
||
color: #fff;
|
||
}
|
||
`}</style>
|
||
</Layout>
|
||
);
|
||
}
|