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:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+160
View File
@@ -0,0 +1,160 @@
/**
* 员工端 — 规章制度公示页面
* 展示已公示制度列表,员工可阅读并签收确认
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
import PortalNav from './PortalNav'
const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default function MyPolicies() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
/** 已公示制度列表 */
const { data: list, isLoading } = useQuery<any>({
queryKey: ['portal-policies'],
queryFn: async () => {
const res = await portalApi.get('/policies') as any
return res.data ?? []
},
})
/** 制度详情 */
const { data: detail, isLoading: detailLoading } = useQuery<any>({
queryKey: ['portal-policy', selectedId],
queryFn: async () => {
if (!selectedId) return null
const res = await portalApi.get(`/policies/${selectedId}`) as any
return res.data ?? null
},
enabled: !!selectedId,
})
/** 阅读确认 */
const readMutation = useMutation({
mutationFn: (id: string) => portalApi.post(`/policies/${id}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-policies'] })
queryClient.invalidateQueries({ queryKey: ['portal-policy', selectedId] })
},
})
if (selectedId) {
return (
<div className="min-h-screen bg-surface">
<div className="max-w-md mx-auto py-6 px-4">
<PortalNav />
<button
onClick={() => setSelectedId(null)}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm mb-4"
>
<ArrowLeft className="w-4 h-4" />
</button>
{detailLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : detail ? (
<Card>
<div className="flex items-center gap-2 mb-3">
<FileText className="w-5 h-5 text-primary" />
<h1 className="text-base font-semibold">{detail.title}</h1>
</div>
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-gray-500">
{detail.publishedAt?.slice(0, 10) || '-'}
</span>
{detail.hasRead ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-100 text-green-700">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
</span>
)}
</div>
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6">
{detail.content || '暂无内容'}
</div>
<div className="border-t pt-4">
{detail.hasRead ? (
<div className="text-center text-xs text-gray-500">
{detail.readAt?.slice(0, 19).replace('T', ' ')}
</div>
) : (
<Button
className="w-full"
onClick={() => readMutation.mutate(detail.id)}
disabled={readMutation.isPending}
>
{readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'}
</Button>
)}
</div>
</Card>
) : (
<EmptyState title="制度不存在" description="该制度可能已被撤回" />
)}
</div>
</div>
)
}
return (
<div className="min-h-screen bg-surface">
<div className="max-w-md mx-auto py-6 px-4">
<PortalNav />
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无公示制度" description="公司尚未公示任何规章制度" />
) : (
<div className="space-y-2">
{list.map((p: any) => (
<Card key={p.id} className="cursor-pointer hover:shadow-md transition-shadow" >
<div onClick={() => setSelectedId(p.id)} className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{p.title}</span>
{p.hasRead ? (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{p.publishedAt?.slice(0, 10) || '-'}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
</div>
</Card>
))}
</div>
)}
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 员工端底部导航栏
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
const navItems = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
{ path: '/portal/contract', label: '我的合同', icon: FileText },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
]
export default function PortalNav() {
const location = useLocation()
const navigate = useNavigate()
const handleLogout = () => {
localStorage.removeItem('portalToken')
localStorage.removeItem('portalEmployee')
navigate('/portal/login')
}
return (
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<div className="flex items-center gap-4">
{navItems.map((item) => {
const Icon = item.icon
const active = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-1 text-sm ${active ? 'text-primary font-medium' : 'text-gray-500 hover:text-gray-700'}`}
>
<Icon className="w-4 h-4" />
{item.label}
</Link>
)
})}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600"
>
<LogOut className="w-4 h-4" />
退
</button>
</div>
)
}