feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header - 侧边栏菜单分组间距增大,分组间分隔线 - 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计 - 修复Policies.tsx民主程序推进bug(字段名/API路径/参数) - 用工文本模板变量名英文转中文显示 - 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY) - 通知示例数据补充 - h2标题统一为text-sm font-medium - 新增run.md
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 面包屑导航组件
|
||||
* 根据当前路由自动生成分组 > 页面 的层级面包屑
|
||||
*/
|
||||
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronRight, Home } from 'lucide-react'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
group: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
||||
'/': { group: '工作台', label: '总览' },
|
||||
'/roster': { group: '员工管理', label: '花名册' },
|
||||
'/attendance': { group: '员工管理', label: '考勤确认' },
|
||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
||||
'/money': { group: '薪税社保', label: '薪税管理' },
|
||||
'/social': { group: '薪税社保', label: '社保公积金' },
|
||||
'/evidence': { group: '合规风控', label: '证据链' },
|
||||
'/policies': { group: '合规风控', label: '规章制度' },
|
||||
'/tools/health-check': { group: '合规风控', label: '用工体检' },
|
||||
'/tools/medical-period': { group: '合规风控', label: '医疗期计算' },
|
||||
'/tools/annual-value': { group: '合规风控', label: '年度价值' },
|
||||
'/ai-assistant': { group: 'AI 辅助', label: 'AI 顾问' },
|
||||
'/templates': { group: 'AI 辅助', label: '文本模板' },
|
||||
'/notifications': { group: '系统', label: '通知管理' },
|
||||
'/audit': { group: '系统', label: '操作日志' },
|
||||
'/settings': { group: '系统', label: '设置' },
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const location = useLocation()
|
||||
const path = location.pathname
|
||||
const item = ROUTE_MAP[path]
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-xs text-gray-400" aria-label="面包屑导航">
|
||||
<Link to="/" className="flex items-center gap-1 hover:text-gray-600 transition-colors">
|
||||
<Home className="w-3 h-3" />
|
||||
<span>首页</span>
|
||||
</Link>
|
||||
{item.group !== '工作台' && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
<span className="text-gray-400">{item.group}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
<span className="text-gray-700 font-medium">{item.label}</span>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx'
|
||||
|
||||
export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={clsx('max-w-content mx-auto px-4', className)}>
|
||||
<div className={clsx('w-full px-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 侧边栏导航组件
|
||||
* 分组式菜单结构,支持折叠/展开,移动端抽屉模式
|
||||
*/
|
||||
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
LayoutDashboard, Users, CalendarCheck, UserX,
|
||||
Calculator, Shield,
|
||||
FileSearch, FileText, Stethoscope, HeartPulse, Award,
|
||||
Bot, BookMarked,
|
||||
Bell, ScrollText, Settings,
|
||||
ChevronDown, ChevronRight,
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: '工作台',
|
||||
items: [
|
||||
{ path: '/', label: '总览', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '员工管理',
|
||||
items: [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
|
||||
{ path: '/termination', label: '解聘补偿', icon: UserX },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '薪税社保',
|
||||
items: [
|
||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '合规风控',
|
||||
items: [
|
||||
{ path: '/evidence', label: '证据链', icon: FileSearch },
|
||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
||||
{ path: '/tools/medical-period', label: '医疗期计算', icon: HeartPulse },
|
||||
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AI 辅助',
|
||||
items: [
|
||||
{ path: '/ai-assistant', label: 'AI 顾问', icon: Bot },
|
||||
{ path: '/templates', label: '文本模板', icon: BookMarked },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/audit', label: '操作日志', icon: ScrollText },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 侧边栏导航
|
||||
*/
|
||||
export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
|
||||
const location = useLocation()
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(navGroups.map(g => g.title)))
|
||||
|
||||
const toggleGroup = (title: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(title)) next.delete(title)
|
||||
else next.add(title)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/'
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
aria-label="关闭侧边栏"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed md:sticky top-0 left-0 z-50 md:z-auto',
|
||||
'w-52 h-screen md:h-screen flex-shrink-0',
|
||||
'bg-white border-r border-gray-200',
|
||||
'flex flex-col',
|
||||
'transition-transform duration-200',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Logo 区 */}
|
||||
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
|
||||
<Building2 className="w-5 h-5 text-primary" />
|
||||
<span className="font-bold text-sm text-gray-900">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-3">
|
||||
{navGroups.map((group, idx) => {
|
||||
const isExpanded = expandedGroups.has(group.title)
|
||||
const hasActiveItem = group.items.some(item => isActive(item.path))
|
||||
|
||||
return (
|
||||
<div key={group.title} className={idx > 0 ? 'pt-2 border-t border-gray-100' : ''}>
|
||||
{/* 分组标题 */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
className={clsx(
|
||||
'flex items-center justify-between w-full px-2 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
hasActiveItem ? 'text-gray-800' : 'text-gray-500 hover:text-gray-700',
|
||||
)}
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{/* 菜单项 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.path)
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-800',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Building2, ChevronDown, Settings as SettingsIcon, Bell } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, Settings as SettingsIcon, Bell, Menu } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import clsx from 'clsx'
|
||||
import Breadcrumb from './Breadcrumb'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: '总览' },
|
||||
{ path: '/roster', label: '员工管理' },
|
||||
{ path: '/money', label: '薪税' },
|
||||
{ path: '/social', label: '社保公积金' },
|
||||
{ path: '/termination', label: '解聘补偿' },
|
||||
{ path: '/ai-assistant', label: 'AI顾问' },
|
||||
]
|
||||
|
||||
export default function TopNav() {
|
||||
const location = useLocation()
|
||||
export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -32,47 +22,33 @@ export default function TopNav() {
|
||||
const riskCount = dashboardData?.riskSummary?.pending || 0
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
||||
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
|
||||
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 shrink-0">
|
||||
<Building2 className="w-5 h-5 text-primary" />
|
||||
<span className="hidden sm:inline">用工合规助手</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors relative',
|
||||
location.pathname === tab.path
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-gray-600 hover:bg-gray-100',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.path === '/' && riskCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
|
||||
{riskCount > 99 ? '99+' : riskCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-gray-200">
|
||||
<div className="h-14 flex items-center justify-between px-4">
|
||||
{/* 左侧:hamburger(移动端)+ 面包屑 */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-1.5 rounded-md hover:bg-gray-100"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
|
||||
{/* 右侧:通知 + 设置 + 用户菜单 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
|
||||
<SettingsIcon className="w-4 h-4 text-gray-600" />
|
||||
</Link>
|
||||
<button className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
|
||||
<Link to="/notifications" className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
|
||||
<Bell className="w-4 h-4 text-gray-600" />
|
||||
{riskCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
|
||||
{riskCount > 99 ? '99+' : riskCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
|
||||
Reference in New Issue
Block a user