Files
TurboHR/frontend/src/pages/Settings.tsx
T
freedakgmail fb36b10402 feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
2026-07-29 08:35:29 +08:00

1312 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import { surveyPages, totalFeatures } from '../data/surveyData'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import' | 'export'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: async () => {
const res = await api.get('/settings/org') as any
return res.data
},
})
const { data: usersData } = useQuery<any>({
queryKey: ['users'],
queryFn: async () => {
const res = await api.get('/settings/users') as any
return res.data
},
})
const updateOrgMutation = useMutation({
mutationFn: (data: any) => api.put('/settings/org', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['org-settings'] }),
})
const sections = [
{ key: 'org' as const, label: '企业信息', icon: Building2 },
{ key: 'users' as const, label: '用户管理', icon: Users },
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
]
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<div className="flex gap-1 border-b">
{sections.map((s) => {
const Icon = s.icon
return (
<button
key={s.key}
onClick={() => setActiveSection(s.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{s.label}
</button>
)
})}
</div>
{activeSection === 'org' && (
<OrgSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} saving={updateOrgMutation.isPending} />
)}
{activeSection === 'users' && <UserSettings usersData={usersData} />}
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<ExportSettings />
</Card>
)}
</div>
)
}
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
const [form, setForm] = useState({
name: '',
contactName: '',
contactPhone: '',
payrollFrequency: 1,
retirementReminderEnabled: false,
})
useEffect(() => {
if (orgData) {
setForm({
name: orgData.name || '',
contactName: orgData.contactName || '',
contactPhone: orgData.contactPhone || '',
payrollFrequency: orgData.payrollFrequency || 1,
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
})
}
}, [orgData])
return (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
</div>
<div>
<Label></Label>
<Input value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} placeholder="联系人姓名" />
</div>
<div>
<Label></Label>
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<div>
<Label></Label>
<Select value={String(form.payrollFrequency)} onChange={(e) => setForm({ ...form, payrollFrequency: Number(e.target.value) })}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</Select>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
<RetirementSection enabled={form.retirementReminderEnabled} onToggle={(v) => { setForm({ ...form, retirementReminderEnabled: v }); onSave({ ...form, retirementReminderEnabled: v }) }} />
</Card>
)
}
function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
const queryClient = useQueryClient()
const [confirming, setConfirming] = useState(false)
const { data: policyData, isLoading } = useQuery<any>({
queryKey: ['retirement-policy'],
queryFn: async () => {
const res = await api.get('/settings/retirement-policy') as any
return res.data
},
enabled,
})
const confirmMutation = useMutation({
mutationFn: (id: string) => api.post(`/settings/retirement-policy/${id}/confirm`),
onSuccess: () => {
toast.success('退休政策已确认生效')
setConfirming(false)
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
},
onError: () => toast.error('确认失败'),
})
const confirmed = policyData?.confirmed
const pending = policyData?.pending
const renderPolicyRules = () => (
<div className="space-y-2">
<div className="text-xs font-medium text-gray-600">退 退</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">60 63</div>
<div className="text-xs text-gray-400">41</div>
</div>
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">55 58</div>
<div className="text-xs text-gray-400">41</div>
</div>
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
<div className="text-xs text-gray-500"></div>
<div className="text-sm font-semibold text-gray-700">50 55</div>
<div className="text-xs text-gray-400">21</div>
</div>
</div>
<p className="text-xs text-gray-400">退退</p>
</div>
)
return (
<div className="mt-6 pt-6 border-t">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-primary" />
<h3 className="font-medium">退</h3>
{enabled && (
<div className="flex gap-2 ml-4">
{pending && (
<Button size="sm" onClick={() => { setConfirming(true); confirmMutation.mutate(pending.id) }} disabled={confirming}>
{confirming ? '确认中...' : '确认生效'}
</Button>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
<button
type="button"
onClick={() => onToggle(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
</div>
{enabled && (
<div className="space-y-3">
{/* 当前生效政策 */}
{confirmed && (
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-green-800">v{confirmed.version}</span>
<span className="text-xs text-gray-500 ml-auto">
{new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')}
</span>
</div>
{renderPolicyRules()}
</div>
)}
{/* 待确认政策 */}
{pending && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
<span className="text-sm font-medium text-orange-800">v{pending.version}</span>
</div>
{renderPolicyRules()}
</div>
)}
{/* 无政策 */}
{!confirmed && !pending && !isLoading && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-center">
<p className="text-sm text-gray-500">退</p>
</div>
)}
{isLoading && <p className="text-sm text-gray-400 text-center">...</p>}
</div>
)}
</div>
)
}
function UserSettings({ usersData }: { usersData: any }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const [editingUser, setEditingUser] = useState<any>(null)
const users = usersData || []
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/settings/users/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
const toggleDisableMutation = useMutation({
mutationFn: (id: string) => api.patch(`/settings/users/${id}/toggle-disable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-b last:border-0">
<td className="py-3 px-3 font-medium">{u.name}</td>
<td className="py-3 px-3 text-gray-600">{u.phone}</td>
<td className="py-3 px-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'}
</span>
</td>
<td className="py-3 px-3">
<span className={u.disabled ? 'text-danger' : 'text-safe'}>
{u.disabled ? '已禁用' : '正常'}
</span>
</td>
<td className="py-3 px-3 text-gray-500">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
</td>
<td className="py-3 px-3">
<div className="flex gap-2">
<button className="text-primary hover:underline" onClick={() => setEditingUser(u)}></button>
<button
className={u.disabled ? 'text-safe hover:underline' : 'text-danger hover:underline'}
onClick={() => toggleDisableMutation.mutate(u.id)}
>
{u.disabled ? '启用' : '禁用'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<AddUserModal open={showAddModal} onClose={() => setShowAddModal(false)} />
<EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={(data) => { updateMutation.mutate({ id: editingUser.id, data }); setEditingUser(null) }} />
</Card>
)
}
function EditUserModal({ user, onClose, onSave }: { user: any; onClose: () => void; onSave: (data: any) => void }) {
const [form, setForm] = useState({ name: '', phone: '', role: 'HR' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (user) {
setForm({ name: user.name || '', phone: user.phone || '', role: user.role || 'HR' })
setError('')
}
}, [user])
const handleSubmit = async () => {
setLoading(true)
setError('')
try {
onSave(form)
} catch (err: any) {
setError(err.response?.data?.error?.message || '保存失败')
} finally {
setLoading(false)
}
}
if (!user) return null
return (
<Modal open={!!user} onClose={onClose} title="编辑用户">
<div className="space-y-3">
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
</div>
<div>
<Label></Label>
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="HR">HR</option>
<option value="ADMIN"></option>
<option value="VIEWER"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone}>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [form, setForm] = useState({ name: '', phone: '', password: '', role: 'HR' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async () => {
setLoading(true)
setError('')
try {
await api.post('/settings/users', form)
onClose()
} catch (err: any) {
setError(err.response?.data?.error?.message || '添加失败')
} finally {
setLoading(false)
}
}
return (
<Modal open={open} onClose={onClose} title="添加用户">
<div className="space-y-3">
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="HR">HR</option>
<option value="ADMIN"></option>
<option value="VIEWER"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.password}>
{loading ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Modal>
)
}
function ExportSettings() {
const [format, setFormat] = useState<'json' | 'excel'>('json')
const [mask, setMask] = useState(false)
const [gzip, setGzip] = useState(true)
const [exporting, setExporting] = useState(false)
const [selectedModules, setSelectedModules] = useState<Record<string, boolean>>({
employees: true, contracts: true, terminations: true, payrollBatches: true,
payslips: true, socialRecords: true, housingRecords: true, riskItems: true,
})
const moduleLabels: Record<string, string> = {
employees: '员工信息', contracts: '劳动合同', terminations: '离职记录',
payrollBatches: '发薪批次', payslips: '工资条', socialRecords: '社保记录',
housingRecords: '公积金记录', riskItems: '风险项',
}
const handleExport = async () => {
setExporting(true)
try {
const token = useAuthStore.getState().accessToken
const modules = Object.keys(selectedModules).filter(k => selectedModules[k]).join(',')
const params = new URLSearchParams({ format, mask: String(mask), modules })
if (format === 'json' && !gzip) params.set('gzip', 'false')
const res = await fetch(`/api/v1/export/all?${params}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const ext = format === 'excel' ? 'xlsx' : (gzip ? 'json.gz' : 'json')
a.download = `export-${new Date().toISOString().slice(0, 10)}.${ext}`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('导出失败')
}
}
return (
<div className="space-y-3">
<p className="text-xs text-gray-500"></p>
<div className="grid grid-cols-2 gap-2">
{Object.keys(moduleLabels).map(key => (
<label key={key} className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={selectedModules[key]}
onChange={(e) => setSelectedModules({ ...selectedModules, [key]: e.target.checked })}
/>
{moduleLabels[key]}
</label>
))}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-xs">
<input type="radio" checked={format === 'json'} onChange={() => setFormat('json')} />
JSON
</label>
<label className="flex items-center gap-2 text-xs">
<input type="radio" checked={format === 'excel'} onChange={() => setFormat('excel')} />
Excel
</label>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={mask} onChange={(e) => setMask(e.target.checked)} />
</label>
{format === 'json' && (
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={gzip} onChange={(e) => setGzip(e.target.checked)} />
Gzip
</label>
)}
</div>
<Button variant="secondary" size="sm" onClick={handleExport} disabled={exporting}>
<Download className="w-4 h-4 mr-1" />{exporting ? '导出中...' : '导出选中数据'}
</Button>
{/* 问卷结果分析导出 */}
<div className="border-t border-gray-200 pt-3 mt-3">
<div className="flex items-center gap-2 mb-2">
<ClipboardList className="w-4 h-4 text-primary" />
<h3 className="text-sm font-medium"></h3>
</div>
<p className="text-xs text-gray-500 mb-2"> Markdown </p>
<Button variant="secondary" size="sm" onClick={handleSurveyExport}>
<Download className="w-4 h-4 mr-1" />MD
</Button>
</div>
</div>
)
}
/** 导出问卷结果为 Markdown 文档 */
function handleSurveyExport() {
try {
const raw = localStorage.getItem('survey-results')
if (!raw) {
toast.info('暂无已提交的问卷结果,请先在功能调查问卷中提交')
return
}
const data = JSON.parse(raw)
const items: Array<{ featureId: string; score: number; useful: string; remark: string }> = data.items || []
if (items.length === 0) {
toast.info('问卷结果为空')
return
}
const scoreMap = new Map(items.map(i => [i.featureId, i]))
const usefulLabels: Record<string, string> = { yes: '有用', no: '无用', maybe: '待定', '': '' }
const submittedAt = data.submittedAt ? new Date(data.submittedAt).toLocaleString('zh-CN') : ''
let md = `# 功能调查问卷结果分析\n\n`
md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
if (submittedAt) md += `> **提交时间:** ${submittedAt}\n`
md += `> **总功能数:** ${totalFeatures}\n`
md += `> **已评功能数:** ${items.length}\n\n`
const rated = items.filter(i => i.score > 0)
const avgScore = rated.length > 0 ? (rated.reduce((s, i) => s + i.score, 0) / rated.length).toFixed(2) : '0'
const usefulCount = items.filter(i => i.useful === 'yes').length
const notUsefulCount = items.filter(i => i.useful === 'no').length
const maybeCount = items.filter(i => i.useful === 'maybe').length
md += `## 统计概览\n\n`
md += `| 指标 | 数值 |\n|------|------|\n`
md += `| 已评功能数 | ${items.length} / ${totalFeatures} |\n`
md += `| 平均评分 | ${avgScore} / 5 |\n`
md += `| 标记有用 | ${usefulCount} |\n`
md += `| 标记无用 | ${notUsefulCount} |\n`
md += `| 标记待定 | ${maybeCount} |\n\n`
md += `## 评分分布\n\n`
for (let s = 5; s >= 1; s--) {
const count = rated.filter(i => i.score === s).length
const pct = rated.length > 0 ? ((count / rated.length) * 100).toFixed(1) : '0'
md += `- **${s}星**${count} 项(${pct}%\n`
}
md += `\n`
md += `## 详细评分\n\n`
for (const page of surveyPages) {
const pageItems = page.features.filter(f => scoreMap.has(f.id))
if (pageItems.length === 0) continue
md += `### ${page.id}. ${page.name}\n\n`
md += `> 📍 ${page.menu}`
if (page.tab) md += ` | Tab: ${page.tab}`
md += `\n\n`
md += `| # | 功能 | 评分 | 有用性 | 备注 |\n`
md += `|---|------|:----:|:------:|------|\n`
for (const f of pageItems) {
const s = scoreMap.get(f.id)!
const stars = '★'.repeat(s.score) + '☆'.repeat(5 - s.score)
md += `| ${f.id} | ${f.name} | ${stars} (${s.score}) | ${usefulLabels[s.useful] || ''} | ${s.remark || ''} |\n`
}
md += `\n`
}
md += `## 低分功能(≤2分)\n\n`
const lowScore = rated.filter(i => i.score <= 2).sort((a, b) => a.score - b.score)
if (lowScore.length > 0) {
md += `| # | 功能 | 评分 | 备注 |\n|---|------|:----:|------|\n`
for (const item of lowScore) {
const feat = surveyPages.flatMap(p => p.features).find(f => f.id === item.featureId)
md += `| ${item.featureId} | ${feat?.name || item.featureId} | ${item.score} | ${item.remark || ''} |\n`
}
} else {
md += `无低分功能\n`
}
md += `\n`
md += `## 高分功能(≥4分)\n\n`
const highScore = rated.filter(i => i.score >= 4).sort((a, b) => b.score - a.score)
if (highScore.length > 0) {
md += `| # | 功能 | 评分 | 备注 |\n|---|------|:----:|------|\n`
for (const item of highScore) {
const feat = surveyPages.flatMap(p => p.features).find(f => f.id === item.featureId)
md += `| ${item.featureId} | ${feat?.name || item.featureId} | ${item.score} | ${item.remark || ''} |\n`
}
} else {
md += `无高分功能\n`
}
md += `\n---\n*由企业用工专家系统自动生成*\n`
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `survey-results-${new Date().toISOString().slice(0, 10)}.md`
a.click()
URL.revokeObjectURL(url)
toast.success('问卷结果已导出为 Markdown 文档')
} catch {
toast.error('导出问卷结果失败')
}
}
function PlanSettings({ orgData }: { orgData: any }) {
const queryClient = useQueryClient()
const plan = orgData?.plan || 'FREE'
const { data: usageData } = useQuery<any>({
queryKey: ['usage'],
queryFn: async () => {
const res = await api.get('/settings/usage') as any
return res.data
},
})
const planMutation = useMutation({
mutationFn: (newPlan: string) => api.put('/settings/plan', { plan: newPlan }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['org'] })
queryClient.invalidateQueries({ queryKey: ['usage'] })
},
})
const plans = [
{ key: 'FREE', label: '免费版', price: '¥0/月', features: ['10人以内', '基础风险检测', '10次AI问答/月'] },
{ key: 'PRO', label: '专业版', price: '¥299/月', features: ['100人以内', '全功能风险检测', '100次AI问答/月', '合同审查'] },
{ key: 'ENTERPRISE', label: '企业版', price: '联系客服', features: ['无限人数', '无限AI问答', '专属客服', 'API接入'] },
]
return (
<div className="space-y-4">
{usageData && (
<Card>
<h3 className="text-xs font-medium text-gray-700 mb-3"></h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
<div>
<div className="text-gray-500"></div>
<div className="font-medium text-base">{usageData.employeeCount}<span className="text-gray-500 text-xs">/{usageData.maxEmployees}</span></div>
</div>
<div>
<div className="text-gray-500">AI </div>
<div className="font-medium text-base">{usageData.aiConversations}</div>
</div>
<div>
<div className="text-gray-500"></div>
<div className="font-medium text-base">{usageData.contracts}</div>
</div>
</div>
</Card>
)}
<div className="grid md:grid-cols-3 gap-4">
{plans.map((p) => (
<Card key={p.key}>
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
<div className="font-medium">{p.label}</div>
<div className={`text-base font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
</div>
<div className="p-4 space-y-2">
{p.features.map((f, i) => (
<div key={i} className="text-xs text-gray-600 flex items-center gap-2">
<span className="text-safe"></span> {f}
</div>
))}
<div className="pt-2">
{plan === p.key ? (
<div className="text-xs text-center text-primary font-medium"></div>
) : (
<Button
variant="secondary"
className="w-full"
size="sm"
onClick={() => {
if (confirm(`确定切换到${p.label}`)) planMutation.mutate(p.key)
}}
disabled={planMutation.isPending}
>
{planMutation.isPending ? '切换中...' : '升级'}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
)
}
function NotificationSettings() {
const queryClient = useQueryClient()
const [form, setForm] = useState<any>({})
const [checkResult, setCheckResult] = useState<string>('')
const { data: setting } = useQuery<any>({
queryKey: ['notification-settings'],
queryFn: async () => {
const res = await api.get('/notifications/settings') as any
return res.data
},
})
const { data: logsData } = useQuery<any>({
queryKey: ['notification-logs'],
queryFn: async () => {
const res = await api.get('/notifications/logs', { params: { pageSize: 10 } }) as any
return res.data
},
})
useEffect(() => {
if (setting) setForm(setting)
}, [setting])
const updateMutation = useMutation({
mutationFn: (data: any) => api.put('/notifications/settings', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
})
const checkMutation = useMutation({
mutationFn: () => api.post('/notifications/check-contracts') as any,
onSuccess: (res: any) => {
setCheckResult(`检查完成:发现 ${res.data.checked} 个即将到期的合同,已发送 ${res.data.notified} 条通知`)
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
},
})
const testWechatMutation = useMutation({
mutationFn: () => api.post('/notifications/test', { channel: 'wechat' }) as any,
onSuccess: (res: any) => {
toast.success(res.success ? res.data.message : (res.error?.message || '测试失败'))
},
})
const testEmailMutation = useMutation({
mutationFn: () => api.post('/notifications/test', { channel: 'email' }) as any,
onSuccess: (res: any) => {
toast.success(res.success ? res.data.message : (res.error?.message || '测试失败'))
},
})
const logs = logsData?.items || []
return (
<div className="space-y-3">
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-3">
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
</label>
<div>
<Label></Label>
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
</div>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
</label>
<div className="border-t pt-3 space-y-3">
<div className="text-xs font-medium"></div>
<div className="text-xs text-gray-500"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.payrollDay ?? 10} onChange={(e) => setForm({ ...form, payrollDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.housingFundDay ?? 15} onChange={(e) => setForm({ ...form, housingFundDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.taxDay ?? 15} onChange={(e) => setForm({ ...form, taxDay: Number(e.target.value) })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<Label> Webhook</Label>
<div className="flex gap-2">
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
<Button variant="secondary" size="sm" onClick={() => testWechatMutation.mutate()} disabled={testWechatMutation.isPending || !form.wechatWebhook}>
{testWechatMutation.isPending ? '测试中...' : '测试'}
</Button>
</div>
</div>
<label className="flex items-center justify-between">
<span className="text-xs"></span>
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
</label>
{form.emailNotify && (
<div>
<Label></Label>
<div className="flex gap-2">
<Input value={form.email || ''} onChange={(e) => setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
<Button variant="secondary" size="sm" onClick={() => testEmailMutation.mutate()} disabled={testEmailMutation.isPending || !form.email}>
{testEmailMutation.isPending ? '测试中...' : '测试'}
</Button>
</div>
</div>
)}
<Button onClick={() => updateMutation.mutate(form)} disabled={updateMutation.isPending}>
{updateMutation.isPending ? '保存中...' : '保存设置'}
</Button>
</div>
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
{checkMutation.isPending ? '检查中...' : '立即检查'}
</Button>
</div>
{checkResult && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs mb-3">{checkResult}</div>
)}
{logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="text-xs border-b last:border-0 py-2">
<div className="font-medium">{log.title}</div>
<div className="text-gray-500 text-xs mt-0.5">{log.content}</div>
<div className="text-gray-500 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-xs text-center py-4"></div>
)}
</Card>
</div>
)
}
export function ImportSettings() {
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
return (
<div className="space-y-3">
<div className="flex gap-1 border-b">
<button
onClick={() => setImportType('init')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setImportType('monthly')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
{importType === 'init' ? <InitImport /> : <MonthlyImport />}
</div>
)
}
function InitImport() {
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [result, setResult] = useState<any>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [preview, setPreview] = useState<any>(null)
const [previewing, setPreviewing] = useState(false)
const [previewTab, setPreviewTab] = useState('employees')
const handlePreview = async () => {
if (!file) return
setPreviewing(true)
setError('')
setPreview(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/v1/import/excel/preview', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '预览失败')
} else {
setPreview(data.data)
}
} catch (e: any) {
setError(e?.message || '预览失败')
} finally {
setPreviewing(false)
}
}
const handleExportErrors = async () => {
if (!preview?.errors?.length) return
try {
const token = useAuthStore.getState().accessToken
const res = await fetch('/api/v1/import/excel/error-log', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ errors: preview.errors }),
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `import-errors-${Date.now()}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('导出错误日志失败')
}
}
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
setResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/v1/import/excel', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
} else {
setResult(data.data)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
} catch (e: any) {
setError(e?.message || '上传失败')
} finally {
setUploading(false)
}
}
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '员工导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('下载模板失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<p className="text-xs text-gray-500">使//</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={handlePreview} disabled={!file || previewing}>
{previewing ? '预览中...' : '预览数据'}
</Button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError(''); setPreview(null) }} className="hidden" id="import-file-init" />
<label htmlFor="import-file-init" className="cursor-pointer text-xs text-primary hover:underline">
{file ? file.name : '点击选择 Excel 文件'}
</label>
</div>
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
{preview && (
<div className="border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-medium">
{preview.summary?.totalRows || 0} {preview.summary?.normalRows || 0} {preview.summary?.errorRows || 0}
</div>
{preview.errors?.length > 0 && (
<Button variant="secondary" size="sm" onClick={handleExportErrors}>
<Download className="w-4 h-4 mr-1" />
</Button>
)}
</div>
<div className="flex gap-1 border-b">
{['employees', 'contracts', 'overtime', 'disciplinary', 'attendance'].map(tab => {
const labels: any = { employees: '员工信息', contracts: '劳动合同', overtime: '加班记录', disciplinary: '违纪记录', attendance: '考勤记录' }
const rows = preview[tab] || []
if (rows.length === 0) return null
return (
<button key={tab} onClick={() => setPreviewTab(tab)}
className={`px-2 py-1 text-xs border-b-2 ${previewTab === tab ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}>
{labels[tab]} ({rows.length})
</button>
)
})}
</div>
<div className="overflow-x-auto max-h-60 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-1 px-2"></th>
<th className="py-1 px-2"></th>
<th className="py-1 px-2"></th>
<th className="py-1 px-2">/</th>
</tr>
</thead>
<tbody>
{(preview[previewTab] || []).map((row: any, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 px-2 text-gray-500">{row.rowNo}</td>
<td className="py-1 px-2">{row.name || row.idCard || '—'}</td>
<td className="py-1 px-2">
<span className={row.status === 'error' ? 'text-danger' : row.status === 'warning' ? 'text-amber-600' : 'text-safe'}>
{row.status === 'error' ? '错误' : row.status === 'warning' ? '警告' : '正常'}
</span>
</td>
<td className="py-1 px-2 text-gray-500">
{row.errors?.join('; ') || row.warnings?.join('; ') || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{result && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
<div>{result.employees} </div>
<div>{result.contracts} </div>
{result.overtime > 0 && <div>{result.overtime} </div>}
{result.disciplinary > 0 && <div>{result.disciplinary} </div>}
{result.attendance > 0 && <div>{result.attendance} </div>}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-amber-600">{result.errors.length}</div>
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleUpload} disabled={!file || uploading}>
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div className="mt-2 text-gray-500"> Sheet </div>
</div>
</Card>
</div>
)
}
function MonthlyImport() {
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [result, setResult] = useState<any>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const handleUpload = async () => {
if (!file) return
setUploading(true)
setError('')
setResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', file)
formData.append('month', month)
const res = await fetch('/api/v1/import/monthly', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
} else {
setResult(data.data)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
} catch (e: any) {
setError(e?.message || '上传失败')
} finally {
setUploading(false)
}
}
const handleDownloadTemplate = async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/monthly-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '月度增减员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('下载模板失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-700 mb-1"></h3>
<p className="text-xs text-gray-500">/</p>
</div>
<div className="flex items-center gap-3">
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-40" />
</div>
<div className="pt-5">
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-monthly" />
<label htmlFor="import-file-monthly" className="cursor-pointer text-xs text-primary hover:underline">
{file ? file.name : '点击选择 Excel 文件'}
</label>
</div>
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
{result && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium">{result.month} </div>
{result.attendance > 0 && <div>{result.attendance} </div>}
{result.overtime > 0 && <div>{result.overtime} </div>}
{result.salaryChanges > 0 && <div>{result.salaryChanges} </div>}
{result.socialInsChanges > 0 && <div>{result.socialInsChanges} </div>}
{result.housingFundChanges > 0 && <div>{result.housingFundChanges} </div>}
{result.strategies && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-gray-600"></div>
{Object.entries(result.strategies).map(([k, v]) => (
<div key={k} className="text-gray-500">{k}{v as string}</div>
))}
</div>
)}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="flex items-center justify-between">
<div className="font-medium text-amber-600">{result.errors.length}</div>
<button className="text-xs text-primary hover:underline" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const errorList = result.errors.map((e: string, i: number) => ({ sheet: '月度导入', row: i + 2, name: '', errors: [e] }))
const res = await fetch('/api/v1/import/excel/error-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ errors: errorList }),
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `import-errors-${Date.now()}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('导出失败') }
}}></button>
</div>
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end">
<Button onClick={handleUpload} disabled={!file || uploading}>
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b>//</div>
<div>- <b></b>//</div>
<div className="mt-2 text-gray-500"> Sheet </div>
</div>
</Card>
</div>
)
}