feat: 调整后台左侧导航分组层级 (#27)

改了什么:按看板、奖励审核、数据配置重新组织左侧导航,二级入口展示在一级标题右下方;补齐主干大盘页缺失的类型字段,保证类型检查通过。

验证方式:npx tsc --noEmit;浏览器检查 /dashboard 左侧导航顺序和二级缩进。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #27
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
This commit was merged in pull request #27.
This commit is contained in:
chenshuobo
2026-06-29 14:52:46 +08:00
committed by marco
parent a1d9923f33
commit d7129186c5
2 changed files with 319 additions and 31 deletions
+243 -29
View File
@@ -11,7 +11,6 @@ import {
HeartOutlined, HeartOutlined,
LogoutOutlined, LogoutOutlined,
MessageOutlined, MessageOutlined,
MobileOutlined,
MoneyCollectOutlined, MoneyCollectOutlined,
NotificationOutlined, NotificationOutlined,
ProfileOutlined, ProfileOutlined,
@@ -20,25 +19,65 @@ import {
TeamOutlined, TeamOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Avatar, Dropdown, Layout, Menu } from 'antd'; import { Avatar, Dropdown, Layout, Tooltip } from 'antd';
import { clearAuth, getAdmin, getToken } from '@/lib/auth'; import { clearAuth, getAdmin, getToken } from '@/lib/auth';
import type { AdminInfo } from '@/lib/types'; import type { AdminInfo } from '@/lib/types';
const { Sider, Header, Content } = Layout; const { Sider, Header, Content } = Layout;
const MENU = [ type NavItem = {
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' }, key: string;
{ key: '/users', icon: <UserOutlined />, label: '用户管理' }, icon: React.ReactNode;
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' }, label: string;
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' }, superOnly?: boolean;
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' }, };
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' }, type NavGroup =
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' }, | (NavItem & { children?: never })
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' }, | {
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' }, key: string;
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' }, icon: React.ReactNode;
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' }, label: string;
children: NavItem[];
superOnly?: boolean;
};
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: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
{ key: '/device-liveness', icon: <HeartOutlined />, 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: '管理员', superOnly: true }, { key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' }, { key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' }, { key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
@@ -48,6 +87,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [admin, setAdmin] = useState<AdminInfo | null>(null); const [admin, setAdmin] = useState<AdminInfo | null>(null);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
@@ -59,14 +99,19 @@ export default function MainLayout({ children }: { children: React.ReactNode })
if (!admin) return null; // 守卫期间不闪烁内容 if (!admin) return null; // 守卫期间不闪烁内容
const items = MENU.filter((m) => !m.superOnly || admin.role === 'super_admin').map((m) => ({
key: m.key,
icon: m.icon,
label: m.label,
}));
// 选中态:取路径一级(/users/123 → /users) // 选中态:取路径一级(/users/123 → /users)
const selectedKey = '/' + (pathname.split('/')[1] || 'dashboard'); const selectedKey = '/' + (pathname.split('/')[1] || 'dashboard');
const canShow = (item: { superOnly?: boolean }) => !item.superOnly || admin.role === 'super_admin';
const visibleGroups = NAV_GROUPS
.filter(canShow)
.map((group) => {
if (!hasChildren(group)) return group;
return { ...group, children: group.children.filter(canShow) };
})
.filter((group) => !hasChildren(group) || group.children.length > 0);
const flatNavItems = visibleGroups.flatMap((group) =>
hasChildren(group) ? group.children : [group],
);
const logout = () => { const logout = () => {
clearAuth(); clearAuth();
@@ -75,7 +120,15 @@ export default function MainLayout({ children }: { children: React.ReactNode })
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Sider theme="dark" breakpoint="lg" collapsible> <Sider
theme="dark"
breakpoint="lg"
collapsible
collapsed={collapsed}
collapsedWidth={72}
width={220}
onCollapse={setCollapsed}
>
<div <div
style={{ style={{
height: 48, height: 48,
@@ -88,13 +141,61 @@ export default function MainLayout({ children }: { children: React.ReactNode })
> >
</div> </div>
<Menu <nav className={`side-nav${collapsed ? ' side-nav-collapsed' : ''}`} aria-label="后台导航">
theme="dark" {collapsed
mode="inline" ? flatNavItems.map((item) => (
selectedKeys={[selectedKey]} <Tooltip key={item.key} title={item.label} placement="right">
items={items} <button
onClick={({ key }) => router.push(key)} 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> </Sider>
<Layout> <Layout>
<Header <Header
@@ -120,6 +221,119 @@ export default function MainLayout({ children }: { children: React.ReactNode })
</Header> </Header>
<Content style={{ margin: 24 }}>{children}</Content> <Content style={{ margin: 24 }}>{children}</Content>
</Layout> </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> </Layout>
); );
} }
+76 -2
View File
@@ -391,6 +391,12 @@ export interface AdRevenueTypeStat {
revenue_yuan: number; // 该类型预估收益合计(元) revenue_yuan: number; // 该类型预估收益合计(元)
} }
export interface AdRevenueTypeBreakdown extends AdRevenueTypeStat {
ad_type: string;
expected_coin: number;
actual_coin: number;
}
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。 // 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
export interface AdRevenueRow { export interface AdRevenueRow {
event_key: string; // 事件稳定唯一键(前端 rowKey) event_key: string; // 事件稳定唯一键(前端 rowKey)
@@ -436,9 +442,52 @@ export interface AdRevenueReport {
total_expected_coin: number; total_expected_coin: number;
total_actual_coin: number; total_actual_coin: number;
mismatch_count: number; // 应发≠实发的发奖条数 mismatch_count: number; // 应发≠实发的发奖条数
by_ad_type?: Record<string, AdRevenueTypeBreakdown>;
items: AdRevenueRow[]; items: AdRevenueRow[];
} }
export interface DashboardPeriodTrendPoint {
date: string;
active_users: number;
new_users: number;
comparisons: number;
}
export interface DashboardPeriodStats {
date_from: string;
date_to: string;
users: {
new: number;
active: number;
retained_new_users: number;
retention_rate: number | null;
retention_note: string;
};
comparison: {
total: number;
success: number;
success_rate: number;
ordered: number;
average_duration_ms: number | null;
average_saved_cents: number | null;
};
coins: {
granted_total: number;
reward_video_coin_total: number;
feed_ad_coin_total: number;
signin_coin_total: number;
signin_boost_coin_total: number;
task_coin_total: number;
coupon_reward_coin_total: number;
comparison_reward_coin_total: number;
regular_task_coin_total: number;
};
cash: {
withdraw_success_cents: number;
};
trend: DashboardPeriodTrendPoint[];
}
export interface DashboardOverview { export interface DashboardOverview {
users: { users: {
total: number; total: number;
@@ -448,7 +497,17 @@ export interface DashboardOverview {
new_today: number; new_today: number;
dau: number; dau: number;
}; };
coins: { granted_total: number }; coins: {
granted_total: number;
reward_video_coin_total?: number;
reward_video_watch_count?: number;
feed_ad_coin_total?: number;
feed_ad_watch_count?: number;
signin_coin_total?: number;
signin_count?: number;
signin_boost_coin_total?: number;
signin_boost_watch_count?: number;
};
cash: { cash: {
withdraw_success_cents: number; withdraw_success_cents: number;
withdraw_pending_count: number; withdraw_pending_count: number;
@@ -456,8 +515,23 @@ export interface DashboardOverview {
withdraw_failed_count: number; withdraw_failed_count: number;
}; };
comparison: { total: number; success: number; success_rate: number }; comparison: { total: number; success: number; success_rate: number };
period: DashboardPeriodStats;
feedback: { new: number }; feedback: { new: number };
cps: { available: boolean; note: string }; cps: {
available: boolean;
note: string;
meituan_order_count?: number;
meituan_commission_cents?: number;
meituan_hit_count?: number;
meituan_miss_count?: number;
meituan_unknown_rate_count?: number;
meituan_hit_rate?: number | null;
jd_order_count?: number;
jd_commission_cents?: number;
jd_actual_commission_cents?: number;
jd_estimated_commission_cents?: number;
jd_invalid_count?: number;
};
} }
export interface PriceReport { export interface PriceReport {