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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user