fix: 恢复后台左侧分组导航并补充领券数据入口 (#32)

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #32
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
This commit was merged in pull request #32.
This commit is contained in:
chenshuobo
2026-07-01 21:47:46 +08:00
committed by marco
parent 3102a96bd3
commit af74ecf9d3
2 changed files with 247 additions and 33 deletions
+2 -2
View File
@@ -932,7 +932,7 @@ export default function DashboardPage() {
value={fmtInt(periodData?.coins.reward_video_coin_total)} value={fmtInt(periodData?.coins.reward_video_coin_total)}
delta={rewardVideoCoinRatio.value} delta={rewardVideoCoinRatio.value}
deltaTone={rewardVideoCoinRatio.tone} deltaTone={rewardVideoCoinRatio.tone}
hint="激励视频金币已按产品口径计入领券奖励金币,这里单独展示来源占比。" hint="独立统计激励视频发放,不再重复计入领券奖励。"
/> />
<StatCard <StatCard
title="常规任务金币" title="常规任务金币"
@@ -955,7 +955,7 @@ export default function DashboardPage() {
<b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b> <b>{fmtCents(periodData?.cash.withdraw_success_cents)}</b>
<b>{fmtCoinAmount(data.coins.granted_total)}</b> <b>{fmtCoinAmount(data.coins.granted_total)}</b>
<b>{fmtCents(data.cash.withdraw_success_cents)}</b>/ <b>{fmtCents(data.cash.withdraw_success_cents)}</b>/
/ 4
</div> </div>
</div> </div>
</section> </section>
+241 -27
View File
@@ -12,7 +12,6 @@ import {
HeartOutlined, HeartOutlined,
LogoutOutlined, LogoutOutlined,
MessageOutlined, MessageOutlined,
MobileOutlined,
MoneyCollectOutlined, MoneyCollectOutlined,
NotificationOutlined, NotificationOutlined,
ProfileOutlined, ProfileOutlined,
@@ -21,26 +20,66 @@ 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: string;
icon: React.ReactNode;
label: string;
superOnly?: boolean;
};
type NavGroup =
| (NavItem & { children?: never })
| {
key: string;
icon: React.ReactNode;
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: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '低价审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '用户反馈' },
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' }, { key: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' },
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' }, { key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' }, { 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: '/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: '审计日志' },
@@ -50,6 +89,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()) {
@@ -61,14 +101,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) => ({ // 选中态:取路径一级(/users/123 -> /users)
key: m.key,
icon: m.icon,
label: m.label,
}));
// 选中态:取路径一级(/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();
@@ -77,7 +122,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,
@@ -90,13 +143,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
@@ -122,6 +223,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>
); );
} }