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
@@ -0,0 +1,251 @@
/**
* 年度价值报告页面
* 量化系统为企业创造的价值:ROI + 规避损失 + 节约工时 + 月度时间轴
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { TrendingUp, Save, History, Download, ShieldCheck, Clock, DollarSign, Sparkles, Users, FileText, Calculator, Award } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
function fmtMoney(n: number): string {
if (n >= 10000) return `¥${(n / 10000).toFixed(1)}`
return `¥${n.toLocaleString('zh-CN')}`
}
/**
* 年度价值报告页面
*/
export default function AnnualValueReport() {
const queryClient = useQueryClient()
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(currentYear)
const [showHistory, setShowHistory] = useState(false)
const { data: report, isLoading } = useQuery<any>({
queryKey: ['annual-value', year],
queryFn: async () => {
const res = await api.get(`/dashboard/annual-value?year=${year}`) as any
return res.data
},
})
const { data: history } = useQuery<any>({
queryKey: ['annual-value-history'],
queryFn: async () => {
const res = await api.get('/dashboard/annual-value/history') as any
return res.data
},
enabled: showHistory,
})
const saveMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/dashboard/annual-value/save', { year }) as any
return res.data
},
onSuccess: () => {
toast.success('报告已保存')
queryClient.invalidateQueries({ queryKey: ['annual-value-history'] })
},
onError: () => toast.error('保存失败'),
})
if (isLoading) {
return <div className="text-center py-8 text-gray-500">...</div>
}
if (!report) return null
const metricCards = [
{ label: '规避损失', value: fmtMoney(report.lossAvoided || 0), icon: ShieldCheck, color: 'text-safe', bg: 'bg-green-50' },
{ label: '节约工时', value: `${report.timeSaved || 0}h`, icon: Clock, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '节约成本', value: fmtMoney(report.costSaved || 0), icon: DollarSign, color: 'text-primary', bg: 'bg-indigo-50' },
{ label: '总价值', value: fmtMoney(report.totalValue || 0), icon: TrendingUp, color: 'text-amber-600', bg: 'bg-amber-50' },
]
const stats = [
{ label: '处理风险', value: report.metrics?.risksResolved ?? 0, icon: ShieldCheck, color: 'text-danger' },
{ label: 'AI 咨询', value: report.metrics?.aiQueries ?? 0, icon: Sparkles, color: 'text-primary' },
{ label: '合同审查', value: report.metrics?.contractsReviewed ?? 0, icon: FileText, color: 'text-blue-600' },
{ label: '签订合同', value: report.metrics?.contractsSigned ?? 0, icon: FileText, color: 'text-safe' },
{ label: '算薪批次', value: report.metrics?.payrollProcessed ?? 0, icon: Calculator, color: 'text-amber-600' },
{ label: '在管员工', value: report.metrics?.employeesManaged ?? 0, icon: Users, color: 'text-gray-700' },
{ label: '解聘处理', value: report.metrics?.terminations ?? 0, icon: Users, color: 'text-orange-600' },
{ label: '制度公示', value: report.metrics?.policiesPublished ?? 0, icon: FileText, color: 'text-purple-600' },
]
const timelineData = (report.timeline || []).map((t: any) => ({
name: `${t.month}`,
risks: t.value,
event: t.event,
}))
return (
<div className="space-y-3">
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500">{year} · </p>
</div>
<div className="flex items-center gap-2">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-2 py-1 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
<option key={y} value={y}>{y} </option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => setShowHistory(!showHistory)}>
<History className="w-4 h-4 mr-1" />
{showHistory ? '收起' : '历史'}
</Button>
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* ROI 横幅 */}
<Card className="bg-gradient-to-r from-primary/10 to-amber-50 border-primary/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/10">
<TrendingUp className="w-8 h-8 text-primary" />
</div>
<div>
<div className="text-xs text-gray-500"> (ROI)</div>
<div className="text-3xl font-bold text-primary">{report.roi}%</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-amber-600">{fmtMoney(report.totalValue)}</div>
</div>
</div>
<p className="mt-3 text-sm text-gray-700">{report.summary}</p>
</Card>
{/* 4 核心指标 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{metricCards.map((m) => {
const Icon = m.icon
return (
<Card key={m.label} className="flex items-center gap-2.5">
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg}`}>
<Icon className={`w-5 h-5 ${m.color}`} />
</div>
<div>
<div className={`text-base font-bold ${m.color}`}>{m.value}</div>
<div className="text-xs text-gray-500">{m.label}</div>
</div>
</Card>
)
})}
</div>
{/* 历史报告 */}
{showHistory && (
<Card>
<h2 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<History className="w-4 h-4 text-gray-500" />
</h2>
{history && history.length > 0 ? (
<div className="space-y-1.5">
{history.map((r: any) => (
<div key={r.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium">{r.year} </span>
<span className="font-bold text-primary">ROI {r.roi}%</span>
<span className="text-gray-500">{r.summary}</span>
</div>
<span className="text-gray-400">{new Date(r.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-3"></div>
)}
</Card>
)}
{/* 月度风险趋势 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={timelineData}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
formatter={(v: any) => [`${v}`, '风险']}
contentStyle={{ fontSize: 12, borderRadius: 6, border: '1px solid #e5e7eb' }}
/>
<Bar dataKey="risks" radius={[4, 4, 0, 0]}>
{timelineData.map((d: any, i: number) => (
<Cell key={i} fill={d.risks > 5 ? '#EF4444' : d.risks > 0 ? '#F59E0B' : '#16A34A'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* 8 项运营统计 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((s) => {
const Icon = s.icon
return (
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
<Icon className={`w-4 h-4 ${s.color}`} />
<div>
<div className="text-sm font-bold">{s.value}</div>
<div className="text-xs text-gray-500">{s.label}</div>
</div>
</div>
)
})}
</div>
</Card>
{/* 价值计算说明 */}
<Card className="bg-gray-50">
<h2 className="text-sm font-medium mb-2"></h2>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-safe mt-1 flex-shrink-0" />
<span><b></b></span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1 flex-shrink-0" />
<span><b></b>AI × 0.5h + × 1h + × 2h + × 1h</span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
<span><b></b> × 100 /hHR </span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1 flex-shrink-0" />
<span><b></b> + </span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-1 flex-shrink-0" />
<span><b>ROI</b> ÷ ¥12,000× 100%</span>
</div>
</div>
</Card>
</div>
)
}