Files
shaguabijia-admin-web/src/app/(main)/layout.tsx
T
zzhyyyyy b163812ab6 运营后台前端:权限管理(RBAC)+ 系统配置 + 比价记录店/商品搜索
一、权限管理(原「管理员」页,重命名为「权限管理」)
- 重建为 4 视图:账号列表 / 新增·编辑人员 / 角色设置(只读权限矩阵,无权限置灰)/ 角色权限勾选矩阵
- 左侧导航按当前角色的可见页过滤(拉 /me 刷新);角色显示中文名 管理员/运营/财务/技术
- 新增人员自动生成一次性密码;编辑复看已确定登录密码(只读),无留存明文的账号不显示密码

二、系统配置
- 首页数据「保存」改为保存即生效
- 首页轮播种子页加 真实/种子/混播 数据源切换

三、比价记录
- 店/商品拆两列 + 各自搜索框;整格换行不截断;搜索命中黄底高亮

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:53:08 +08:00

355 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}