b163812ab6
一、权限管理(原「管理员」页,重命名为「权限管理」) - 重建为 4 视图:账号列表 / 新增·编辑人员 / 角色设置(只读权限矩阵,无权限置灰)/ 角色权限勾选矩阵 - 左侧导航按当前角色的可见页过滤(拉 /me 刷新);角色显示中文名 管理员/运营/财务/技术 - 新增人员自动生成一次性密码;编辑复看已确定登录密码(只读),无留存明文的账号不显示密码 二、系统配置 - 首页数据「保存」改为保存即生效 - 首页轮播种子页加 真实/种子/混播 数据源切换 三、比价记录 - 店/商品拆两列 + 各自搜索框;整格换行不截断;搜索命中黄底高亮 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
355 lines
11 KiB
TypeScript
355 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,
|
||
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: '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>
|
||
);
|
||
}
|