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:
+243
-29
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user