feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板

This commit is contained in:
selfrelease
2026-07-31 17:40:49 +08:00
parent c15e11ec22
commit 821a62e3f8
16 changed files with 3177 additions and 55 deletions
@@ -0,0 +1,54 @@
/**
* 应用外壳组件 — 统一页面布局结构
* 包含 PageHeader 区域 + 内容区域,配合 SidebarNav 和 TopNav 使用
*/
import { ReactNode } from 'react'
import clsx from 'clsx'
interface AppShellProps {
children: ReactNode
className?: string
}
/**
* 应用外壳 — 限制内容最大宽度,统一内边距
*/
export function AppShell({ children, className }: AppShellProps) {
return (
<div className={clsx('w-full max-w-content mx-auto px-4 md:px-6', className)}>
{children}
</div>
)
}
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
status?: ReactNode
className?: string
}
/**
* 页面头部 — 统一标题、说明、状态和操作按钮区域
*/
export function PageHeader({ title, description, actions, status, className }: PageHeaderProps) {
return (
<div className={clsx('flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-4', className)}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold text-ink-900 truncate">{title}</h1>
{status}
</div>
{description && (
<p className="text-sm text-ink-600 mt-0.5">{description}</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2 shrink-0">
{actions}
</div>
)}
</div>
)
}
@@ -1,14 +1,13 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, Users, Calculator, UserX, Bot, Shield } from 'lucide-react'
import { Home, Users, Calculator, CalendarCheck, Shield } from 'lucide-react'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览', icon: Home },
{ path: '/roster', label: '员工', icon: Users },
{ path: '/money', label: '薪', icon: Calculator },
{ path: '/social', label: '社保', icon: Shield },
{ path: '/termination', label: '解聘', icon: UserX },
{ path: '/ai-assistant', label: 'AI', icon: Bot },
{ path: '/', label: '首页', icon: Home },
{ path: '/roster', label: '团队', icon: Users },
{ path: '/money', label: '薪', icon: Calculator },
{ path: '/attendance', label: '时间', icon: CalendarCheck },
{ path: '/evidence', label: '合规', icon: Shield },
]
export default function MobileTabBar() {
+21 -19
View File
@@ -30,31 +30,36 @@ interface NavGroup {
const navGroups: NavGroup[] = [
{
title: '工作台',
title: '首页',
items: [
{ path: '/', label: '总览', icon: LayoutDashboard },
{ path: '/', label: '工作台', icon: LayoutDashboard },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
],
},
{
title: '员工管理',
title: '团队',
items: [
{ path: '/roster', label: '花名册', icon: Users },
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
{ path: '/termination', label: '解聘补偿', icon: UserX },
{ path: '/termination', label: '离职管理', icon: UserX },
{ path: '/special-status', label: '特殊状态', icon: Heart },
],
},
{
title: '薪税社保',
title: '薪',
items: [
{ path: '/money', label: '薪税管理', icon: Calculator },
{ path: '/social', label: '社保公积金', icon: Shield },
],
},
{
title: '合规风控',
title: '时间',
items: [
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
],
},
{
title: '合规',
items: [
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText },
@@ -64,15 +69,10 @@ const navGroups: NavGroup[] = [
],
},
{
title: 'AI 辅助',
title: '更多',
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 },
@@ -85,7 +85,14 @@ const navGroups: NavGroup[] = [
*/
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 isActive = (path: string) => {
if (path === '/') return location.pathname === '/'
return location.pathname.startsWith(path)
}
const activeGroup = navGroups.find(g => g.items.some(item => isActive(item.path)))
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(activeGroup ? [activeGroup.title] : ['首页'])
)
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
@@ -96,11 +103,6 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
})
}
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/'
return location.pathname.startsWith(path)
}
return (
<>
{/* 移动端遮罩 */}
+127
View File
@@ -0,0 +1,127 @@
/**
* 数据表格组件 — 统一表头、行样式、排序状态和空状态
* 支持列定义、行点击、选中行、固定列、对齐和空状态
*/
import { ReactNode } from 'react'
import clsx from 'clsx'
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
export interface Column<T> {
key: string
header: string
render?: (row: T) => ReactNode
sortable?: boolean
align?: 'left' | 'center' | 'right'
width?: string
fixed?: 'left' | 'right'
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
rowKey: (row: T) => string
onRowClick?: (row: T) => void
sortBy?: string
sortOrder?: 'asc' | 'desc'
onSort?: (key: string) => void
emptyState?: ReactNode
dense?: boolean
className?: string
}
/**
* 数据表格 — 支持排序、行点击、空状态和密度切换
*/
export function DataTable<T>({
columns,
data,
rowKey,
onRowClick,
sortBy,
sortOrder,
onSort,
emptyState,
dense,
className,
}: DataTableProps<T>) {
const alignClass = (align?: string) =>
align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left'
return (
<div className={clsx('overflow-x-auto border border-border-default rounded-card bg-surface-card', className)}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-default bg-surface-muted">
{columns.map((col) => (
<th
key={col.key}
className={clsx(
'px-3 font-medium text-ink-600 whitespace-nowrap select-none',
dense ? 'py-1.5' : 'py-2.5',
alignClass(col.align),
col.fixed === 'left' && 'sticky left-0 z-10 bg-surface-muted',
col.fixed === 'right' && 'sticky right-0 z-10 bg-surface-muted',
onSort && col.sortable && 'cursor-pointer hover:text-ink-900',
)}
style={col.width ? { width: col.width } : undefined}
onClick={col.sortable && onSort ? () => onSort(col.key) : undefined}
>
<span className="inline-flex items-center gap-1">
{col.header}
{col.sortable && onSort && (
<span className="inline-flex">
{sortBy === col.key ? (
sortOrder === 'asc' ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)
) : (
<ChevronsUpDown className="w-3.5 h-3.5 text-ink-400" />
)}
</span>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="text-center py-12 text-ink-400">
{emptyState || '暂无数据'}
</td>
</tr>
) : (
data.map((row) => (
<tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={clsx(
'border-b border-border-subtle transition-colors',
onRowClick && 'cursor-pointer hover:bg-surface-muted',
)}
>
{columns.map((col) => (
<td
key={col.key}
className={clsx(
'px-3 text-ink-900 whitespace-nowrap',
dense ? 'py-1.5' : 'py-2.5',
alignClass(col.align),
col.fixed === 'left' && 'sticky left-0 z-10 bg-surface-card',
col.fixed === 'right' && 'sticky right-0 z-10 bg-surface-card',
)}
>
{col.render ? col.render(row) : (row as any)[col.key]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
/**
* 筛选栏组件 — 统一搜索、筛选条件展示和工具按钮
* 支持搜索框、可移除的筛选条件标签、右侧工具区
*/
import { ReactNode } from 'react'
import clsx from 'clsx'
import { Search, X } from 'lucide-react'
interface FilterBarProps {
searchValue?: string
searchPlaceholder?: string
onSearchChange?: (value: string) => void
filters?: ReactNode
activeChips?: Array<{ label: string; onRemove: () => void }>
tools?: ReactNode
className?: string
}
/**
* 筛选栏 — 搜索 + 筛选条件 + 工具按钮
*/
export function FilterBar({
searchValue,
searchPlaceholder = '搜索…',
onSearchChange,
filters,
activeChips,
tools,
className,
}: FilterBarProps) {
return (
<div className={clsx('flex flex-col gap-2 mb-3', className)}>
{/* 第一行:搜索 + 筛选器 + 工具 */}
<div className="flex flex-wrap items-center gap-2">
{onSearchChange !== undefined && (
<div className="relative flex-1 min-w-[180px] max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-ink-400" />
<input
type="text"
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="w-full pl-8 pr-3 py-1.5 rounded-md border border-border-default bg-surface-card text-sm text-ink-900 focus:outline-none focus:ring-2 focus:ring-brand-600/30 focus:border-brand-600 transition-colors"
/>
</div>
)}
{filters && <div className="flex items-center gap-2 flex-wrap">{filters}</div>}
{tools && <div className="flex items-center gap-2 ml-auto shrink-0">{tools}</div>}
</div>
{/* 第二行:激活的筛选条件标签 */}
{activeChips && activeChips.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{activeChips.map((chip, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-surface-muted text-ink-600"
>
{chip.label}
<button
onClick={chip.onRemove}
className="hover:text-danger transition-colors"
aria-label="移除筛选条件"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
)
}