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,240 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, 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'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
CONTRACT_EXPIRY: '合同到期',
|
||||
CONTRACT_UNSIGNED: '未签合同',
|
||||
OVERTIME: '加班预警',
|
||||
OVERTIME_ALERT: '加班预警',
|
||||
PAYSLIP: '工资条',
|
||||
PAYSLIP_READY: '工资条',
|
||||
RISK_ALERT: '风险预警',
|
||||
SOCIAL_INS: '社保提醒',
|
||||
HOUSING_FUND: '公积金提醒',
|
||||
TAX: '税务提醒',
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
WECHAT: '企业微信',
|
||||
EMAIL: '邮件',
|
||||
IN_APP: '站内',
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知管理页面
|
||||
*/
|
||||
export default function Notifications() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['notification-logs', page, pageSize],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/notifications/logs?page=${page}&pageSize=${pageSize}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery<any>({
|
||||
queryKey: ['notification-settings'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/notifications/settings') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const checkContractsMutation = useMutation({
|
||||
mutationFn: () => api.post('/notifications/check-contracts'),
|
||||
onSuccess: () => {
|
||||
toast.success('合同到期检查已触发')
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
|
||||
},
|
||||
onError: () => toast.error('检查失败'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell 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>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => checkContractsMutation.mutate()} disabled={checkContractsMutation.isPending}>
|
||||
<Send className="w-4 h-4 mr-1" />检查合同到期
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowSettings(true)}>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />通知设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通知设置概览 */}
|
||||
{settings && (
|
||||
<Card>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '合同到期提醒', enabled: settings.contractExpiry, days: settings.expiryDays },
|
||||
{ label: '未签合同提醒', enabled: settings.contractUnsigned },
|
||||
{ label: '加班预警', enabled: settings.overtimeAlert },
|
||||
{ label: '工资条通知', enabled: settings.payslipReady },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
|
||||
{s.enabled ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium truncate">{s.label}</div>
|
||||
{s.days && <div className="text-xs text-gray-500">提前{s.days}天</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 通知记录列表 */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !data?.items || data.items.length === 0 ? (
|
||||
<EmptyState title="暂无通知记录" description="系统将自动发送合同到期、工资条等通知" />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<div className="space-y-1.5">
|
||||
{data.items.map((log: any) => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-2 py-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||
<div className={`flex items-center justify-center w-7 h-7 rounded-lg flex-shrink-0 ${
|
||||
log.status === 'SENT' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{log.title}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{TYPE_LABELS[log.type] || log.type}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-600">{CHANNEL_LABELS[log.channel] || log.channel}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{log.content}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex-shrink-0">
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 通知设置弹窗 */}
|
||||
{showSettings && settings && (
|
||||
<SettingsModal settings={settings} onClose={() => setShowSettings(false)} onSuccess={() => { setShowSettings(false); queryClient.invalidateQueries({ queryKey: ['notification-settings'] }) }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
contractExpiry: settings.contractExpiry ?? true,
|
||||
expiryDays: settings.expiryDays ?? 30,
|
||||
contractUnsigned: settings.contractUnsigned ?? true,
|
||||
overtimeAlert: settings.overtimeAlert ?? true,
|
||||
payslipReady: settings.payslipReady ?? true,
|
||||
payrollDay: settings.payrollDay ?? 10,
|
||||
socialInsDay: settings.socialInsDay ?? 15,
|
||||
housingFundDay: settings.housingFundDay ?? 15,
|
||||
taxDay: settings.taxDay ?? 15,
|
||||
wechatWebhook: settings.wechatWebhook ?? '',
|
||||
emailNotify: settings.emailNotify ?? false,
|
||||
email: settings.email ?? '',
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => api.put('/notifications/settings', form),
|
||||
onSuccess: () => { toast.success('通知设置已保存'); onSuccess() },
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
const toggleItem = (key: string) => setForm(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))
|
||||
|
||||
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 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">通知设置</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">
|
||||
{[
|
||||
{ key: 'contractExpiry', label: '合同到期提醒', desc: '提前提醒即将到期的合同' },
|
||||
{ key: 'contractUnsigned', label: '未签合同提醒', desc: '入职超过30天未签合同' },
|
||||
{ key: 'overtimeAlert', label: '加班预警', desc: '加班时长超过法定上限' },
|
||||
{ key: 'payslipReady', label: '工资条通知', desc: '工资条生成后通知员工' },
|
||||
{ key: 'emailNotify', label: '邮件通知', desc: '通过邮件发送通知' },
|
||||
].map(item => (
|
||||
<div key={item.key} className="flex items-center justify-between p-2 rounded-lg bg-gray-50">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleItem(item.key)}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${form[item.key as keyof typeof form] ? 'bg-primary' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${form[item.key as keyof typeof form] ? 'left-5' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">合同提前提醒天数</label>
|
||||
<input type="number" value={form.expiryDays} onChange={e => setForm(prev => ({ ...prev, expiryDays: parseInt(e.target.value) || 30 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={365} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">发薪日</label>
|
||||
<input type="number" value={form.payrollDay} onChange={e => setForm(prev => ({ ...prev, payrollDay: parseInt(e.target.value) || 10 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={28} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">企业微信 Webhook URL</label>
|
||||
<input type="url" value={form.wechatWebhook} onChange={e => setForm(prev => ({ ...prev, wechatWebhook: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">通知邮箱</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm(prev => ({ ...prev, email: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="hr@example.com" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user