feat: 完成优化1-6全部功能 — Portal安全/AI增强/Settings导入导出/社保公积金版本化
- 优化-1: 社保公积金独立配置+版本化缴费记录+多城市支持+迁移脚本 - 优化-2: AI流式输出/RAG集成/风险角标/审计日志/二维码/批量续签/忘记密码/语音输入/PDF导出/速率限制/套餐人数上限 - 优化-3: 批次重命名/费用实时预览/模拟版本管理/续签合规预检/社保重置/搜索分页/批量解聘/到期预警/税率试算 - 优化-4: 会话历史/待办批量/结果关联档案/风险下钻/预测上下文/附件校验/Tab级联/薪税导出 - 优化-5: 表单回填/用户编辑禁用/导入预览/选择性导出/通知测试/错误日志导出/脱敏导出/gzip压缩 - 优化-6: 工资条确认通知HR/AI上下文增强/电子签名/用量限制修复/入职文件上传/RAG管理/工资趋势/用量事务/验证码加固/审查结构化/链接撤回/超时机制/确认重发/案例转待办
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save } from 'lucide-react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -8,7 +8,7 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case'
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -30,6 +30,7 @@ export default function AIAssistant() {
|
||||
{ key: 'predict', label: '风险预测', icon: Sparkles },
|
||||
{ key: 'review', label: '合同审查', icon: FileSearch },
|
||||
{ key: 'case', label: '案例匹配', icon: Scale },
|
||||
{ key: 'knowledge', label: '知识库', icon: BookOpen },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -58,6 +59,7 @@ export default function AIAssistant() {
|
||||
{tab === 'predict' && <PredictTab />}
|
||||
{tab === 'review' && <ReviewTab />}
|
||||
{tab === 'case' && <CaseTab />}
|
||||
{tab === 'knowledge' && <KnowledgeTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -166,6 +168,8 @@ function ChatTab() {
|
||||
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35 * 1000)
|
||||
const response = await fetch('/api/v1/ai/chat-stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -173,7 +177,9 @@ function ChatTab() {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages: newMessages }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null)
|
||||
@@ -213,7 +219,8 @@ function ChatTab() {
|
||||
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.message || '请稍后重试'}` }])
|
||||
const isTimeout = err.name === 'AbortError'
|
||||
setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -390,7 +397,7 @@ function PredictTab() {
|
||||
|
||||
function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
@@ -406,12 +413,12 @@ function ReviewTab() {
|
||||
const handleReview = async () => {
|
||||
if (!contractText.trim()) return
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await api.post('/ai/review', { contractText }) as any
|
||||
setResult(res.data.result)
|
||||
setResult(res.data)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -420,7 +427,7 @@ function ReviewTab() {
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result })
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
alert('已保存到员工档案')
|
||||
@@ -429,6 +436,12 @@ function ReviewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const levelConfig: Record<string, { color: string; bg: string; label: string }> = {
|
||||
RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' },
|
||||
YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' },
|
||||
GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -456,7 +469,55 @@ function ReviewTab() {
|
||||
<h3 className="font-medium">审查结果</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
{result.error ? (
|
||||
<div className="text-xs text-danger">{result.error}</div>
|
||||
) : result.structured ? (
|
||||
<div className="space-y-3">
|
||||
{/* 合规评分 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">合规评分</span>
|
||||
<span className={`text-lg font-bold ${result.structured.score >= 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}>
|
||||
{result.structured.score}/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 风险项列表 */}
|
||||
{result.structured.riskItems.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">风险项({result.structured.riskItems.length})</h4>
|
||||
{result.structured.riskItems.map((item: any, i: number) => {
|
||||
const cfg = levelConfig[item.level] || levelConfig.YELLOW
|
||||
return (
|
||||
<div key={i} className={`rounded-md p-3 ${cfg.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mb-1">{item.description}</div>
|
||||
<div className="text-xs text-gray-500">建议:{item.suggestion}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 总体建议 */}
|
||||
{result.structured.summary && (
|
||||
<div className="border-t pt-2">
|
||||
<h4 className="text-xs font-medium mb-1">总体建议</h4>
|
||||
<p className="text-xs text-gray-600">{result.structured.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原始文本可展开 */}
|
||||
<details className="border-t pt-2">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">查看原始文本</summary>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap mt-2">{result.text}</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result.text || JSON.stringify(result)}</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -486,6 +547,12 @@ function CaseTab() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showTodoModal, setShowTodoModal] = useState(false)
|
||||
const [todoEmployeeId, setTodoEmployeeId] = useState('')
|
||||
const [todoTitle, setTodoTitle] = useState('')
|
||||
const [todoLevel, setTodoLevel] = useState('MEDIUM')
|
||||
const [todoType, setTodoType] = useState('TERMINATION')
|
||||
const [creatingTodo, setCreatingTodo] = useState(false)
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
@@ -521,6 +588,28 @@ function CaseTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTodo = async () => {
|
||||
if (!todoEmployeeId || !todoTitle) return
|
||||
setCreatingTodo(true)
|
||||
try {
|
||||
await api.post('/ai/case-to-todo', {
|
||||
employeeId: todoEmployeeId,
|
||||
title: todoTitle,
|
||||
description: result.slice(0, 500),
|
||||
level: todoLevel,
|
||||
type: todoType,
|
||||
})
|
||||
setShowTodoModal(false)
|
||||
setTodoEmployeeId('')
|
||||
setTodoTitle('')
|
||||
alert('已创建待办风险项')
|
||||
} catch (err: any) {
|
||||
alert('创建失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
} finally {
|
||||
setCreatingTodo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -546,7 +635,10 @@ function CaseTab() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">分析结果</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowTodoModal(true)}><Plus className="w-4 h-4 mr-1" />转待办</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
@@ -568,6 +660,187 @@ function CaseTab() {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showTodoModal && (
|
||||
<Modal open onClose={() => setShowTodoModal(false)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">转为待办风险项</h3>
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={todoEmployeeId} onChange={(e) => setTodoEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>待办标题</Label>
|
||||
<Input value={todoTitle} onChange={(e) => setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>风险等级</Label>
|
||||
<Select value={todoLevel} onChange={(e) => setTodoLevel(e.target.value)}>
|
||||
<option value="HIGH">高</option>
|
||||
<option value="MEDIUM">中</option>
|
||||
<option value="LOW">低</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>风险类型</Label>
|
||||
<Select value={todoType} onChange={(e) => setTodoType(e.target.value)}>
|
||||
<option value="CONTRACT">合同</option>
|
||||
<option value="SALARY">薪酬</option>
|
||||
<option value="TERMINATION">解聘</option>
|
||||
<option value="MONTHLY">月度</option>
|
||||
<option value="ONBOARDING">入职</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">分析结果将作为待办描述自动填入</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowTodoModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleCreateTodo} disabled={!todoEmployeeId || !todoTitle || creatingTodo}>
|
||||
{creatingTodo ? '创建中...' : '创建待办'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const { data: knowledgeList, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['rag-knowledge'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/rag/list') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: typeof newItem) => {
|
||||
return await api.post('/ai/rag/add', data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
|
||||
setShowAdd(false)
|
||||
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/rag/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const seedMutation = useMutation({
|
||||
mutationFn: () => api.post('/ai/rag/seed'),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newItem.title || !newItem.content) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await addMutation.mutateAsync(newItem)
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">共 {knowledgeList?.length || 0} 条知识</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
|
||||
{seedMutation.isPending ? '初始化中...' : '初始化知识库'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加知识
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !knowledgeList || knowledgeList.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">知识库为空,请点击「初始化知识库」</div></Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{knowledgeList.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{item.category}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">{item.content}</p>
|
||||
<div className="text-xs text-gray-400 mt-1">来源:{item.source}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
className="text-gray-400 hover:text-danger flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<Modal open onClose={() => setShowAdd(false)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">添加知识条目</h3>
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Input value={newItem.title} onChange={(e) => setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>内容</Label>
|
||||
<textarea
|
||||
value={newItem.content}
|
||||
onChange={(e) => setNewItem({ ...newItem, content: e.target.value })}
|
||||
placeholder="法律条文或知识内容"
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>来源</Label>
|
||||
<Input value={newItem.source} onChange={(e) => setNewItem({ ...newItem, source: e.target.value })} placeholder="如:劳动合同法" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>分类</Label>
|
||||
<Select value={newItem.category} onChange={(e) => setNewItem({ ...newItem, category: e.target.value })}>
|
||||
<option value="其他">其他</option>
|
||||
<option value="法律法规">法律法规</option>
|
||||
<option value="司法解释">司法解释</option>
|
||||
<option value="地方性法规">地方性法规</option>
|
||||
<option value="案例分析">案例分析</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={!newItem.title || !newItem.content || adding}>
|
||||
{adding ? '添加中...' : '添加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1677,6 +1677,13 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
error: any
|
||||
}) {
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const { data: cities = ['北京'] } = useQuery<string[]>({
|
||||
queryKey: ['social-config-cities'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config/cities') as any
|
||||
return res.data?.length ? res.data : ['北京']
|
||||
},
|
||||
})
|
||||
const defaultEndDate = (() => {
|
||||
const d = new Date()
|
||||
d.setFullYear(d.getFullYear() + 3)
|
||||
@@ -1845,7 +1852,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>参保城市</Label><select className="w-full text-xs border rounded px-2 py-1.5" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}><option value="北京">北京</option><option value="上海">上海</option><option value="广州">广州</option><option value="深圳">深圳</option><option value="杭州">杭州</option></select></div>
|
||||
<div><Label>参保城市</Label><select className="w-full text-xs border rounded px-2 py-1.5" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</select></div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金</Label>
|
||||
|
||||
+429
-55
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
@@ -76,12 +76,23 @@ export default function Settings() {
|
||||
|
||||
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
|
||||
const [form, setForm] = useState({
|
||||
name: orgData?.data?.name || '',
|
||||
contactName: orgData?.data?.contactName || '',
|
||||
contactPhone: orgData?.data?.contactPhone || '',
|
||||
payrollFrequency: orgData?.data?.payrollFrequency || 1,
|
||||
name: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
payrollFrequency: 1,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (orgData?.data) {
|
||||
setForm({
|
||||
name: orgData.data.name || '',
|
||||
contactName: orgData.data.contactName || '',
|
||||
contactPhone: orgData.data.contactPhone || '',
|
||||
payrollFrequency: orgData.data.payrollFrequency || 1,
|
||||
})
|
||||
}
|
||||
}, [orgData])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">企业信息</h2>
|
||||
@@ -115,39 +126,28 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h3 className="text-xs font-medium text-gray-600 mb-3">数据导出</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">导出全部员工、合同、薪税、社保等数据为 JSON 文件</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch('/api/v1/export/all', {
|
||||
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 = `export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
alert('导出失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />导出全部数据
|
||||
</Button>
|
||||
<ExportSettings />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSettings({ usersData }: { usersData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<any>(null)
|
||||
const users = usersData?.data || []
|
||||
|
||||
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">
|
||||
@@ -164,6 +164,8 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
<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>
|
||||
@@ -176,17 +178,93 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
{u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-safe">正常</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-400">
|
||||
{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)
|
||||
@@ -240,10 +318,110 @@ function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
alert('导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-gray-400">选择需要导出的数据模块和格式</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>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanSettings({ orgData }: { orgData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const plan = orgData?.data?.plan || 'FREE'
|
||||
const maxEmployees = orgData?.data?.maxEmployees || 10
|
||||
|
||||
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问答/月', '合同审查'] },
|
||||
@@ -251,29 +429,60 @@ function PlanSettings({ orgData }: { orgData: any }) {
|
||||
]
|
||||
|
||||
return (
|
||||
<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">升级</Button>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{usageData && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium text-gray-700 mb-3">当前用量</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-400">员工数</div>
|
||||
<div className="font-medium text-base">{usageData.employeeCount}<span className="text-gray-400 text-xs">/{usageData.maxEmployees}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">AI 对话</div>
|
||||
<div className="font-medium text-base">{usageData.aiConversations}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">合同数</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>
|
||||
)
|
||||
}
|
||||
@@ -299,7 +508,7 @@ function NotificationSettings() {
|
||||
},
|
||||
})
|
||||
|
||||
useMemo(() => {
|
||||
useEffect(() => {
|
||||
if (setting) setForm(setting)
|
||||
}, [setting])
|
||||
|
||||
@@ -316,6 +525,20 @@ function NotificationSettings() {
|
||||
},
|
||||
})
|
||||
|
||||
const testWechatMutation = useMutation({
|
||||
mutationFn: () => api.post('/notifications/test', { channel: 'wechat' }) as any,
|
||||
onSuccess: (res: any) => {
|
||||
alert(res.success ? res.data.message : (res.error?.message || '测试失败'))
|
||||
},
|
||||
})
|
||||
|
||||
const testEmailMutation = useMutation({
|
||||
mutationFn: () => api.post('/notifications/test', { channel: 'email' }) as any,
|
||||
onSuccess: (res: any) => {
|
||||
alert(res.success ? res.data.message : (res.error?.message || '测试失败'))
|
||||
},
|
||||
})
|
||||
|
||||
const logs = logsData?.items || []
|
||||
|
||||
return (
|
||||
@@ -367,7 +590,12 @@ function NotificationSettings() {
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>企业微信 Webhook(选填)</Label>
|
||||
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||
<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>
|
||||
@@ -376,7 +604,12 @@ function NotificationSettings() {
|
||||
{form.emailNotify && (
|
||||
<div>
|
||||
<Label>通知邮箱</Label>
|
||||
<Input value={form.email || ''} onChange={(e) => setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
|
||||
<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}>
|
||||
@@ -443,6 +676,60 @@ function InitImport() {
|
||||
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 {
|
||||
alert('导出错误日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
@@ -504,11 +791,14 @@ function InitImport() {
|
||||
<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('') }} className="hidden" id="import-file-init" />
|
||||
<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>
|
||||
@@ -516,6 +806,62 @@ function InitImport() {
|
||||
|
||||
{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-xs">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b text-left 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-400">{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>
|
||||
@@ -652,9 +998,37 @@ function MonthlyImport() {
|
||||
{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="font-medium text-amber-600">部分错误({result.errors.length}条):</div>
|
||||
<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 { alert('导出失败') }
|
||||
}}>导出错误日志</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>
|
||||
|
||||
@@ -17,6 +17,10 @@ export default function ContractConfirm() {
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [devCode, setDevCode] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
@@ -31,10 +35,24 @@ export default function ContractConfirm() {
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setSendingCode(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/portal/contract-confirm/send-code', { token }) as any
|
||||
setCodeSent(true)
|
||||
setDevCode(res.data?.data?.code || '')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '验证码发送失败')
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.post('/portal/contract-confirm', { token, agreed: true })
|
||||
await api.post('/portal/contract-confirm', { token, agreed: true, verifyCode })
|
||||
setConfirmed(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '确认失败')
|
||||
@@ -95,10 +113,36 @@ export default function ContractConfirm() {
|
||||
我已阅读合同内容,确认签署
|
||||
</label>
|
||||
|
||||
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting}>
|
||||
{/* 验证码区域 */}
|
||||
{agreed && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
className="flex-1 px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || codeSent}
|
||||
className="px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{sendingCode ? '发送中' : codeSent ? '已发送' : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{devCode && (
|
||||
<div className="text-xs text-blue-500">开发模式验证码:{devCode}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting || !verifyCode}>
|
||||
{submitting ? '确认中...' : '确认签署'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 确认后将记录签署时间和 IP 地址</div>
|
||||
<div className="text-xs text-gray-400 text-center">📌 确认后将记录签署时间、IP 地址和设备信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, AlertCircle, Check } from 'lucide-react'
|
||||
import { FileText, AlertCircle, Check, RefreshCw } 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'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
@@ -16,6 +18,9 @@ portalApi.interceptors.request.use((config: any) => {
|
||||
})
|
||||
|
||||
export default function MyContract() {
|
||||
const [resending, setResending] = useState(false)
|
||||
const [resendMsg, setResendMsg] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['my-contract'],
|
||||
queryFn: async () => {
|
||||
@@ -31,6 +36,21 @@ export default function MyContract() {
|
||||
? Math.floor((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
|
||||
const isConfirmed = contract?.attachmentName?.startsWith('confirmed:')
|
||||
|
||||
const handleResend = async () => {
|
||||
setResending(true)
|
||||
setResendMsg('')
|
||||
try {
|
||||
const res = await portalApi.post('/contract-confirm/resend', { contractId: contract?.id }) as any
|
||||
setResendMsg(res.data?.data?.message || '重发成功')
|
||||
} catch (err: any) {
|
||||
setResendMsg(err.response?.data?.error?.message || '重发失败')
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
@@ -74,13 +94,22 @@ export default function MyContract() {
|
||||
{/* 签署确认记录 */}
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">签署记录</h3>
|
||||
{contract.attachmentName?.startsWith('confirmed:') ? (
|
||||
{isConfirmed ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" />
|
||||
已确认签署({new Date(contract.attachmentName.slice(10)).toLocaleString()})
|
||||
已确认签署({new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">暂无签署确认记录</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-warning">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
合同尚未确认签署
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{resending ? '重发中...' : '重发确认链接'}
|
||||
</Button>
|
||||
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ClipboardList, Check } from 'lucide-react'
|
||||
import { ClipboardList, Check, Upload, FileText, X } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
|
||||
const FILE_TYPES = [
|
||||
{ key: 'ID_CARD_FRONT', label: '身份证正面' },
|
||||
{ key: 'ID_CARD_BACK', label: '身份证反面' },
|
||||
{ key: 'EDUCATION', label: '学历证明' },
|
||||
{ key: 'BANK_CARD', label: '银行卡照片' },
|
||||
{ key: 'OTHER', label: '其他材料' },
|
||||
]
|
||||
|
||||
interface UploadedFile {
|
||||
fileType: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export default function Onboarding() {
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
@@ -13,6 +28,10 @@ export default function Onboarding() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [currentFileType, setCurrentFileType] = useState('ID_CARD_FRONT')
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
@@ -35,11 +54,36 @@ export default function Onboarding() {
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('fileType', currentFileType)
|
||||
const res = await api.post(`/portal/onboarding/${token}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as any
|
||||
setUploadedFiles([...uploadedFiles, res.data.data])
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '文件上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
setUploadedFiles(uploadedFiles.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/portal/onboarding', { ...form, token })
|
||||
await api.post('/portal/onboarding', { ...form, token, attachments: uploadedFiles })
|
||||
setSubmitted(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '提交失败')
|
||||
@@ -113,6 +157,53 @@ export default function Onboarding() {
|
||||
<Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<div className="border-t pt-3">
|
||||
<Label>入职材料上传</Label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{FILE_TYPES.map((ft) => (
|
||||
<button
|
||||
key={ft.key}
|
||||
type="button"
|
||||
onClick={() => setCurrentFileType(ft.key)}
|
||||
className={`px-2 py-1 rounded text-xs ${currentFileType === ft.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{ft.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf,.bmp"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-md text-xs text-gray-500 hover:border-primary"
|
||||
>
|
||||
{uploading ? '上传中...' : `点击上传${FILE_TYPES.find(f => f.key === currentFileType)?.label || ''}`}
|
||||
</button>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{uploadedFiles.map((f, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-2 py-1 bg-gray-50 rounded text-xs">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<FileText className="w-3 h-3 flex-shrink-0 text-gray-400" />
|
||||
<span className="truncate">{FILE_TYPES.find(ft => ft.key === f.fileType)?.label || f.fileType}: {f.fileName}</span>
|
||||
</div>
|
||||
<button onClick={() => removeFile(i)} className="text-gray-400 hover:text-danger flex-shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.idCard}>
|
||||
{loading ? '提交中...' : '提交'}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { DollarSign, Check } from 'lucide-react'
|
||||
import { DollarSign, Check, TrendingUp, Download } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
@@ -20,6 +20,7 @@ portalApi.interceptors.request.use((config: any) => {
|
||||
export default function Payslip() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['payslip', month],
|
||||
@@ -29,6 +30,14 @@ export default function Payslip() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: history } = useQuery<any[]>({
|
||||
queryKey: ['payslip-history'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/payslip/history') as any
|
||||
return res.data?.data ?? []
|
||||
},
|
||||
})
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
|
||||
@@ -36,6 +45,31 @@ export default function Payslip() {
|
||||
|
||||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||||
|
||||
const handleExport = () => {
|
||||
if (!history || history.length === 0) return
|
||||
const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态']
|
||||
const rows = history.map((p: any) => [
|
||||
p.month,
|
||||
p.baseSalary,
|
||||
p.overtimePay,
|
||||
p.allowance,
|
||||
p.deduction,
|
||||
p.totalPay,
|
||||
p.confirmedAt ? '已确认' : '未确认',
|
||||
])
|
||||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `工资条_${employee.name || '员工'}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.month))
|
||||
const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
@@ -50,15 +84,48 @@ export default function Payslip() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />趋势
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={!history || history.length === 0}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-4 h-4" />导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showHistory && sortedHistory.length > 0 && (
|
||||
<Card className="mb-4">
|
||||
<h3 className="text-xs font-medium mb-3">近 {sortedHistory.length} 个月工资趋势</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedHistory.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-16 flex-shrink-0">{p.month}</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all"
|
||||
style={{ width: `${(Number(p.totalPay) / maxPay) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
|
||||
Reference in New Issue
Block a user