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
+273
View File
@@ -0,0 +1,273 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } 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'
const STEP_LABELS: Record<string, string> = {
DRAFTING: '起草',
DISCUSSION: '讨论',
CONSULTATION: '协商',
PUBLICATION: '公示',
}
const STEP_ORDER = ['DRAFTING', 'DISCUSSION', 'CONSULTATION', 'PUBLICATION']
/**
* 规章制度民主程序管理页面
*/
export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['policies'],
queryFn: async () => {
const res = await api.get('/policies') as any
return res.data
},
})
const advanceMutation = useMutation({
mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => api.post(`/policies/${id}/advance-step`, { step, note }),
onSuccess: () => {
toast.success('流程步骤已推进')
queryClient.invalidateQueries({ queryKey: ['policies'] })
// 刷新选中制度详情
if (selectedPolicy) {
api.get(`/policies/${selectedPolicy.id}`).then((res: any) => setSelectedPolicy(res.data))
}
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</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="hover:shadow-md transition-shadow cursor-pointer" >
<div onClick={() => setSelectedPolicy(p)}>
<div className="flex items-start 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.status === 'PUBLISHED' ? (
<span className="px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700"></span>
) : (
<span className="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">{STEP_LABELS[STEP_ORDER[(p.democracyProgress?.currentStep || 1) - 1]] || '起草中'}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1 truncate">{p.content?.slice(0, 80) || '暂无内容'}</div>
<div className="flex items-center gap-1 mt-2">
{STEP_ORDER.map((step, i) => {
const currentStep = p.democracyProgress?.currentStep || 1
const isDone = i < currentStep - 1 || p.status === 'PUBLISHED'
const isCurrent = i === currentStep - 1 && p.status !== 'PUBLISHED'
return (
<div key={step} className="flex items-center">
<div className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
isDone ? 'bg-green-100 text-green-700' : isCurrent ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-400'
}`}>
{isDone ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{STEP_LABELS[step]}
</div>
{i < STEP_ORDER.length - 1 && <ChevronRight className="w-3 h-3 text-gray-300" />}
</div>
)
})}
</div>
{p.status === 'PUBLISHED' && p.totalEmployees > 0 && (
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-1.5 overflow-hidden max-w-32">
<div className="bg-green-500 h-full rounded-full" style={{ width: `${p.totalEmployees > 0 ? Math.round((p.readCount / p.totalEmployees) * 100) : 0}%` }} />
</div>
<span className="text-gray-600">{p.readCount}/{p.totalEmployees}</span>
</div>
)}
</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* 详情弹窗 */}
{selectedPolicy && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelectedPolicy(null)}>
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selectedPolicy.title}</h2>
<button onClick={() => setSelectedPolicy(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="text-sm text-gray-600 whitespace-pre-wrap mb-4">{selectedPolicy.content || '暂无内容'}</div>
<div className="space-y-2 border-t pt-3">
<div className="text-xs font-medium text-gray-600"></div>
{(selectedPolicy.democracyProgress?.steps || []).map((s: any, i: number) => {
const isDone = s.status === 'COMPLETED' || (selectedPolicy.status === 'PUBLISHED')
const isCurrent = s.status === 'IN_PROGRESS' && selectedPolicy.status !== 'PUBLISHED'
return (
<div key={i} className={`p-2 rounded-lg ${isDone ? 'bg-green-50' : isCurrent ? 'bg-primary/5' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="text-sm flex items-center gap-1.5">
{isDone ? <CheckCircle className="w-4 h-4 text-green-600" /> : <Clock className="w-4 h-4 text-gray-400" />}
{s.name}
<span className="text-xs text-gray-400 font-normal">· {s.description}</span>
</span>
{isCurrent && (
<Button
size="sm"
onClick={() => {
const note = window.prompt('请输入本步骤备注(可选)') || ''
advanceMutation.mutate({ id: selectedPolicy.id, step: i + 1, note })
}}
>
</Button>
)}
</div>
{s.date && (
<div className="text-xs text-gray-500 mt-1">
{s.status === 'COMPLETED' ? '完成时间' : '开始时间'}{s.date}
{s.note && <span className="ml-2 text-gray-400">{s.note}</span>}
</div>
)}
</div>
)
})}
</div>
{/* 阅读签收统计(仅已公示制度显示) */}
{selectedPolicy.status === 'PUBLISHED' && (
<ReadStats policyId={selectedPolicy.id} />
)}
</div>
</Card>
</div>
)}
{/* 新建弹窗 */}
{showCreate && (
<CreatePolicyModal onClose={() => setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['policies'] }) }} />
)}
</div>
)
}
function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [type, setType] = useState('RULES')
const createMutation = useMutation({
mutationFn: () => api.post('/policies', { title, content, type }),
onSuccess: () => { toast.success('制度已创建'); onSuccess() },
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-600"></label>
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="如:考勤管理制度" />
</div>
<div>
<label className="text-xs text-gray-600"></label>
<select value={type} onChange={e => setType(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option value="RULES"></option>
<option value="NOTICE"></option>
</select>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => createMutation.mutate()} disabled={!title || !content || createMutation.isPending}></Button>
</div>
</div>
</div>
</Card>
</div>
)
}
/**
* 阅读签收统计组件
*/
function ReadStats({ policyId }: { policyId: string }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
const res = await api.get(`/policies/${policyId}/read-stats`) as any
return res.data
},
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
const percent = data.total > 0 ? Math.round((data.readCount / data.total) * 100) : 0
return (
<div className="mt-4 border-t pt-3">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
<div className="bg-green-500 h-full rounded-full transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs text-gray-600 shrink-0">
{data.readCount}/{data.total} {percent}%
</span>
</div>
{data.unreadCount > 0 && (
<div className="text-xs text-amber-600 mb-2">
{data.unreadCount}
</div>
)}
{data.records && data.records.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1">
{data.records.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-gray-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-gray-400">{r.readAt?.slice(0, 16).replace('T', ' ')}</span>
</div>
))}
</div>
)}
</div>
)
}