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:
@@ -98,6 +98,7 @@ enum OnboardingStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ContractConfirmStatus {
|
||||
@@ -157,6 +158,7 @@ model User {
|
||||
passwordHash String
|
||||
name String
|
||||
role Role @default(ADMIN)
|
||||
disabled Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
lastLoginAt DateTime?
|
||||
}
|
||||
@@ -789,3 +791,18 @@ model AIReviewRecord {
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
// ========== RAG 知识库 ==========
|
||||
|
||||
model RagKnowledge {
|
||||
id String @id
|
||||
title String
|
||||
content String
|
||||
source String
|
||||
category String
|
||||
embedding Unsupported("vector(1536)")?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([category])
|
||||
@@map("rag_knowledge")
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'):
|
||||
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
|
||||
const limit = limits[type]
|
||||
if (limit === 0) return
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const count = await prisma.auditLog.count({
|
||||
where: {
|
||||
orgId,
|
||||
action: `AI_${type.toUpperCase()}`,
|
||||
detail: JSON.stringify({ month }) as any,
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
})
|
||||
if (count >= limit) {
|
||||
@@ -59,12 +60,20 @@ async function buildOrgContext(orgId: string): Promise<string> {
|
||||
}),
|
||||
])
|
||||
|
||||
const now = new Date()
|
||||
const empSummary = employees.map((e) => {
|
||||
const contract = e.contracts[0]
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同类型:${contract.contractType}` : '未签合同'}`
|
||||
const daysToExpire = contract?.endDate
|
||||
? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
const specialStatus: string[] = []
|
||||
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
|
||||
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
|
||||
if (e.isWorkInjured) specialStatus.push('工伤')
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同:${contract.contractType},${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
|
||||
}).join('\n')
|
||||
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level})`).join('\n')
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).join('\n')
|
||||
|
||||
return `员工列表(${employees.length}人):
|
||||
${empSummary}
|
||||
@@ -100,12 +109,19 @@ router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
for await (const delta of chatStream(messages, orgContext)) {
|
||||
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
|
||||
let usageRecorded = false
|
||||
try {
|
||||
for await (const delta of chatStream(messages, orgContext)) {
|
||||
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
} finally {
|
||||
if (!usageRecorded) {
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
|
||||
usageRecorded = true
|
||||
}
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
|
||||
} catch (err) {
|
||||
if (!res.headersSent) next(err)
|
||||
else res.end()
|
||||
@@ -121,7 +137,7 @@ router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
await checkUsageLimit(req.user!.orgId, 'review')
|
||||
const result = await reviewContract(contractText)
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'review')
|
||||
res.json({ success: true, data: { result } })
|
||||
res.json({ success: true, data: { text: result.text, structured: result.structured } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
@@ -142,6 +158,34 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
}
|
||||
})
|
||||
|
||||
// 案例匹配结果转待办(RiskItem)
|
||||
router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const schema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'),
|
||||
type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'),
|
||||
})
|
||||
const data = schema.parse(req.body)
|
||||
const risk = await prisma.riskItem.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
level: data.level,
|
||||
type: data.type,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: risk })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const scope = (req.query.scope as string) || 'all'
|
||||
@@ -330,4 +374,31 @@ router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
}
|
||||
})
|
||||
|
||||
// 知识库列表
|
||||
router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const category = req.query.category as string | undefined
|
||||
const items = await prisma.$queryRaw`
|
||||
SELECT id, title, content, source, category, created_at
|
||||
FROM rag_knowledge
|
||||
${category ? prisma.$queryRaw`WHERE category = ${category}` : prisma.$queryRaw``}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
` as any[]
|
||||
res.json({ success: true, data: items })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除知识条目
|
||||
router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}`
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -3,48 +3,155 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { createGzip } from 'zlib'
|
||||
import { Writable } from 'stream'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 敏感字段脱敏
|
||||
function maskIdCard(idCard: string | null): string | null {
|
||||
if (!idCard) return null
|
||||
if (idCard.length >= 11) return idCard.slice(0, 3) + '*'.repeat(idCard.length - 7) + idCard.slice(-4)
|
||||
return idCard
|
||||
}
|
||||
function maskBankAccount(account: string | null): string | null {
|
||||
if (!account) return null
|
||||
if (account.length > 4) return '*'.repeat(account.length - 4) + account.slice(-4)
|
||||
return account
|
||||
}
|
||||
|
||||
// 导出全部数据(支持模块选择、格式选择、脱敏)
|
||||
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const format = (req.query.format as string) || 'json'
|
||||
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
|
||||
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
|
||||
|
||||
const [employees, contracts, terminations, payrollBatches, payslips, socialRecords, housingRecords, riskItems] = await Promise.all([
|
||||
prisma.employee.findMany({ where: { orgId } }),
|
||||
prisma.laborContract.findMany({ where: { orgId } }),
|
||||
prisma.terminationRecord.findMany({ where: { orgId } }),
|
||||
prisma.payrollBatch.findMany({ where: { orgId } }),
|
||||
prisma.payslip.findMany({ where: { orgId } }),
|
||||
prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
|
||||
prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
|
||||
prisma.riskItem.findMany({ where: { orgId } }),
|
||||
])
|
||||
|
||||
const safeEmployees = employees.map((e) => {
|
||||
let salary = 0
|
||||
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
|
||||
let idCard = null
|
||||
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
|
||||
return { ...e, monthlySalary: salary, idCardNumber: idCard }
|
||||
})
|
||||
|
||||
const data = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
orgId,
|
||||
employees: safeEmployees,
|
||||
contracts,
|
||||
terminations,
|
||||
payrollBatches,
|
||||
payslips,
|
||||
socialRecords,
|
||||
housingRecords,
|
||||
riskItems,
|
||||
const fetchMap: Record<string, () => Promise<any>> = {
|
||||
employees: () => prisma.employee.findMany({ where: { orgId } }),
|
||||
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
|
||||
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
|
||||
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
|
||||
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
|
||||
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
|
||||
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
|
||||
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
|
||||
res.json(data)
|
||||
const useGzip = req.query.gzip !== 'false'
|
||||
const batchSize = 500
|
||||
|
||||
if (format === 'excel') {
|
||||
const data: any = { exportedAt: new Date().toISOString(), orgId }
|
||||
|
||||
if (modules.includes('employees')) {
|
||||
const employees = await fetchMap.employees()
|
||||
data.employees = employees.map((e: any) => {
|
||||
let salary = 0
|
||||
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
|
||||
let idCard: string | null = null
|
||||
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
|
||||
let bankAccount: string | null = null
|
||||
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
|
||||
if (mask) {
|
||||
idCard = maskIdCard(idCard)
|
||||
bankAccount = maskBankAccount(bankAccount)
|
||||
if (salary) salary = 0
|
||||
}
|
||||
return { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
|
||||
})
|
||||
}
|
||||
|
||||
for (const mod of modules) {
|
||||
if (mod === 'employees') continue
|
||||
if (fetchMap[mod]) {
|
||||
data[mod] = await fetchMap[mod]()
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
for (const mod of modules) {
|
||||
if (!data[mod] || !data[mod].length) continue
|
||||
const ws = workbook.addWorksheet(mod.slice(0, 31))
|
||||
const rows = data[mod]
|
||||
const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object')
|
||||
ws.columns = keys.map(k => ({ header: k, key: k, width: 18 }))
|
||||
ws.getRow(1).font = { bold: true }
|
||||
for (const row of rows) {
|
||||
const flat: any = {}
|
||||
for (const k of keys) flat[k] = typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k]
|
||||
ws.addRow(flat)
|
||||
}
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} else {
|
||||
// JSON 流式导出 + gzip 压缩
|
||||
if (useGzip) {
|
||||
res.setHeader('Content-Encoding', 'gzip')
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
|
||||
}
|
||||
|
||||
const gzip = useGzip ? createGzip() : null
|
||||
const output: Writable = gzip || res
|
||||
if (gzip) { gzip.pipe(res) }
|
||||
|
||||
const write = (chunk: string) => {
|
||||
output.write(Buffer.from(chunk))
|
||||
}
|
||||
|
||||
write('{"exportedAt":"' + new Date().toISOString() + '","orgId":"' + orgId + '"')
|
||||
|
||||
for (const mod of modules) {
|
||||
write(',"' + mod + '":[')
|
||||
|
||||
if (mod === 'employees') {
|
||||
// 员工数据分批查询,避免内存溢出
|
||||
let skip = 0
|
||||
let first = true
|
||||
while (true) {
|
||||
const batch = await prisma.employee.findMany({ where: { orgId }, skip, take: batchSize })
|
||||
if (batch.length === 0) break
|
||||
for (const e of batch) {
|
||||
let salary = 0
|
||||
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
|
||||
let idCard: string | null = null
|
||||
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
|
||||
let bankAccount: string | null = null
|
||||
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
|
||||
if (mask) {
|
||||
idCard = maskIdCard(idCard)
|
||||
bankAccount = maskBankAccount(bankAccount)
|
||||
if (salary) salary = 0
|
||||
}
|
||||
const row = { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
|
||||
write((first ? '' : ',') + JSON.stringify(row))
|
||||
first = false
|
||||
}
|
||||
skip += batchSize
|
||||
if (batch.length < batchSize) break
|
||||
}
|
||||
} else if (fetchMap[mod]) {
|
||||
const rows = await fetchMap[mod]()
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
write((i === 0 ? '' : ',') + JSON.stringify(rows[i]))
|
||||
}
|
||||
}
|
||||
|
||||
write(']')
|
||||
}
|
||||
|
||||
write('}')
|
||||
if (gzip) gzip.end()
|
||||
else res.end()
|
||||
}
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,54 @@ import prisma from '../lib/prisma'
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
|
||||
// 身份证号格式校验(18位正则 + 校验位算法)
|
||||
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
|
||||
if (!idCard) return { valid: true }
|
||||
const s = idCard.trim()
|
||||
// 15位身份证号升级为18位
|
||||
if (/^\d{15}$/.test(s)) {
|
||||
const upgraded = upgrade15To18(s)
|
||||
return { valid: true, upgraded }
|
||||
}
|
||||
if (!/^\d{17}[\dXx]$/.test(s)) {
|
||||
return { valid: false, error: '身份证号格式错误(应为18位)' }
|
||||
}
|
||||
// 校验位算法
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
|
||||
const expected = checkCodes[sum % 11]
|
||||
if (s.charAt(17).toUpperCase() !== expected) {
|
||||
return { valid: false, error: '身份证号校验位错误' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
function upgrade15To18(s15: string): string {
|
||||
const born = '19' + s15.substring(6, 12)
|
||||
const body = s15.substring(0, 6) + born + s15.substring(12)
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
const sum = body.split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
|
||||
return body + checkCodes[sum % 11]
|
||||
}
|
||||
|
||||
// 社保基数范围校验
|
||||
const SOCIAL_INS_LIMITS: Record<string, { min: number; max: number }> = {
|
||||
'北京': { min: 6326, max: 33891 },
|
||||
'上海': { min: 7310, max: 36549 },
|
||||
'广州': { min: 5284, max: 27501 },
|
||||
'深圳': { min: 3523, max: 27501 },
|
||||
'杭州': { min: 4812, max: 24060 },
|
||||
}
|
||||
function validateSocialBase(base: number, city?: string): { valid: boolean; warning?: string } {
|
||||
if (!city || !SOCIAL_INS_LIMITS[city]) return { valid: true }
|
||||
const limits = SOCIAL_INS_LIMITS[city]
|
||||
if (base < limits.min) return { valid: true, warning: `基数${base}低于${city}下限${limits.min}` }
|
||||
if (base > limits.max) return { valid: true, warning: `基数${base}高于${city}上限${limits.max}` }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
function dateToMonth(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
@@ -35,6 +83,133 @@ function num(v: any): number {
|
||||
return isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
// ========== 导入预览(不写入数据库) ==========
|
||||
|
||||
router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
|
||||
const preview: any = { employees: [], contracts: [], overtime: [], disciplinary: [], attendance: [], errors: [] as any[] }
|
||||
|
||||
const empSheet = wb.Sheets['员工信息']
|
||||
if (empSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(empSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
||||
const hireDate = parseDate(r['入职日期'])
|
||||
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
|
||||
if (row.salary === 0) { row.status = 'error'; row.errors.push('月工资为空') }
|
||||
if (row.idCard) {
|
||||
const idCheck = validateIdCard(row.idCard)
|
||||
if (!idCheck.valid) { row.status = row.status === 'normal' ? 'warning' : row.status; row.warnings.push(idCheck.error!) }
|
||||
if (idCheck.upgraded) { row.idCard = idCheck.upgraded; row.warnings.push('15位身份证已升级为18位') }
|
||||
}
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '员工信息', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.employees.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const contractSheet = wb.Sheets['劳动合同']
|
||||
if (contractSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(contractSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const sd = parseDate(r['合同开始日期'])
|
||||
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.contracts.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const otSheet = wb.Sheets['加班记录']
|
||||
if (otSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(otSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const dt = parseDate(r['日期'])
|
||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.overtime.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const discSheet = wb.Sheets['违纪记录']
|
||||
if (discSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(discSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(r['描述']), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.disciplinary.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const attSheet = wb.Sheets['考勤记录']
|
||||
if (attSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const dt = parseDate(r['日期'])
|
||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.attendance.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
totalRows: preview.employees.length + preview.contracts.length + preview.overtime.length + preview.disciplinary.length + preview.attendance.length,
|
||||
normalRows: 0,
|
||||
warningRows: 0,
|
||||
errorRows: preview.errors.length,
|
||||
sheets: Object.keys(wb.Sheets).filter(s => !s.startsWith('!')),
|
||||
}
|
||||
summary.normalRows = summary.totalRows - summary.errorRows
|
||||
preview.summary = summary
|
||||
|
||||
res.json({ success: true, data: preview })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 错误日志导出 ==========
|
||||
|
||||
router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const { errors } = req.body as { errors: any[] }
|
||||
if (!errors || !errors.length) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无错误数据' } })
|
||||
}
|
||||
const data = errors.map(e => ({
|
||||
'Sheet': e.sheet || '',
|
||||
'行号': e.row || '',
|
||||
'员工姓名': e.name || '',
|
||||
'错误类型': Array.isArray(e.errors) ? e.errors.join('; ') : (e.error || ''),
|
||||
}))
|
||||
const ws = XLSX.utils.json_to_sheet(data)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`)
|
||||
res.send(buf)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
@@ -58,14 +233,21 @@ router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthReq
|
||||
const salary = String(num(r['月工资']))
|
||||
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
|
||||
|
||||
let idCard = val(r['身份证号'])
|
||||
if (idCard) {
|
||||
const idCheck = validateIdCard(idCard)
|
||||
if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue }
|
||||
if (idCheck.upgraded) idCard = idCheck.upgraded
|
||||
}
|
||||
|
||||
const emp = await prisma.employee.create({
|
||||
data: {
|
||||
orgId, name, department: dept, hireDate,
|
||||
monthlySalary: encrypt(salary),
|
||||
gender: val(r['性别']) || null,
|
||||
phone: val(r['手机号']) || null,
|
||||
idCardNumber: val(r['身份证号']) ? encrypt(val(r['身份证号'])) : null,
|
||||
idCardHash: val(r['身份证号']) ? sha256(val(r['身份证号'])) : null,
|
||||
idCardNumber: idCard ? encrypt(idCard) : null,
|
||||
idCardHash: idCard ? sha256(idCard) : null,
|
||||
emergencyContact: val(r['紧急联系人']) || null,
|
||||
emergencyPhone: val(r['紧急联系电话']) || null,
|
||||
address: val(r['住址']) || null,
|
||||
@@ -147,9 +329,12 @@ router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthReq
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const month = dateToMonth(date)
|
||||
const hours = num(r['加班时长'])
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0, createdBy: userId } as any })
|
||||
const hours = num(r['加班时长'])
|
||||
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
|
||||
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
|
||||
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
|
||||
result.overtime++
|
||||
}
|
||||
}
|
||||
@@ -214,7 +399,7 @@ router.get('/template', authMiddleware, async (req: AuthRequest, res: Response)
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
|
||||
|
||||
const otData = [
|
||||
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '加班时长': 2, '加班类型': '工作日加班', '倍率': 1.5, '是否审批': '是' },
|
||||
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
@@ -247,7 +432,7 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
}
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
|
||||
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[] }
|
||||
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
|
||||
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
|
||||
@@ -298,13 +483,16 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
const otMonth = dateToMonth(date)
|
||||
const hours = num(r['加班时长'])
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const wdHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
|
||||
const weHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
|
||||
const hoHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0 } as any,
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
|
||||
update: {
|
||||
weekdayHours: { increment: otType.includes('工作日') ? hours : 0 },
|
||||
weekendHours: { increment: otType.includes('休息日') ? hours : 0 },
|
||||
holidayHours: { increment: otType.includes('法定') ? hours : 0 },
|
||||
weekdayHours: { increment: wdHours },
|
||||
weekendHours: { increment: weHours },
|
||||
holidayHours: { increment: hoHours },
|
||||
},
|
||||
})
|
||||
result.overtime++
|
||||
@@ -347,7 +535,10 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const changeType = val(r['变动类型'])
|
||||
const base = num(r['缴费基数'])
|
||||
const city = val(r['城市']) || '北京'
|
||||
if (changeType === '增员' || changeType === '调基') {
|
||||
const baseCheck = validateSocialBase(base, city)
|
||||
if (baseCheck.warning) result.errors.push(`社保第${i + 2}行警告:${baseCheck.warning}`)
|
||||
// 关闭之前有效记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
|
||||
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
|
||||
@@ -397,7 +588,7 @@ router.get('/monthly-template', authMiddleware, async (req: AuthRequest, res: Re
|
||||
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
|
||||
|
||||
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '加班时长': 2, '加班类型': '工作日加班' }]
|
||||
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
|
||||
@@ -131,4 +131,39 @@ router.post('/check-contracts', async (req: AuthRequest, res: Response, next: Ne
|
||||
}
|
||||
})
|
||||
|
||||
// 测试通知渠道
|
||||
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { channel } = req.body as { channel: 'wechat' | 'email' }
|
||||
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
|
||||
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
|
||||
|
||||
if (channel === 'wechat') {
|
||||
if (!setting.wechatWebhook) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置企业微信 Webhook' } })
|
||||
try {
|
||||
const resp = await fetch(setting.wechatWebhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ msgtype: 'text', text: { content: '【测试消息】通知渠道连接正常,配置有效。' } }),
|
||||
})
|
||||
const data = await resp.json() as any
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
return res.json({ success: false, error: { code: 'TEST_FAILED', message: `Webhook 返回错误: ${data.errmsg || data.errcode}` } })
|
||||
}
|
||||
res.json({ success: true, data: { message: '测试消息已发送到企业微信' } })
|
||||
} catch (e: any) {
|
||||
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
|
||||
}
|
||||
} else if (channel === 'email') {
|
||||
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
|
||||
// 邮件发送(开发阶段仅返回成功)
|
||||
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } })
|
||||
}
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema } from '../schemas/portal.schema'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -59,8 +62,13 @@ router.post('/send-code', async (req, res, next) => {
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||||
}
|
||||
// 频率限制:60秒内不可重复发送
|
||||
const existing = codeStore.get(data.phone)
|
||||
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
|
||||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -75,8 +83,14 @@ router.post('/verify-code', async (req, res, next) => {
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
// 错误次数限制:5次后锁定
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(data.phone)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
@@ -106,11 +120,26 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条历史(最近6个月)
|
||||
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { month: 'desc' },
|
||||
take: 6,
|
||||
})
|
||||
res.json({ success: true, data: payslips })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条确认已阅
|
||||
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
|
||||
@@ -119,6 +148,16 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
where: { id: req.params.id },
|
||||
data: { confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
// 通知 HR
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId: req.employee.orgId,
|
||||
title: '工资条确认通知',
|
||||
content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip})`,
|
||||
type: 'PAYSLIP_CONFIRM',
|
||||
channel: 'IN_APP',
|
||||
},
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -176,23 +215,68 @@ router.post('/onboarding', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署验证码发送
|
||||
router.post('/contract-confirm/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractSendCodeSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
const phone = link.contract.employee.phone
|
||||
if (!phone) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署确认
|
||||
router.post('/contract-confirm', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractConfirmSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
// 验证码校验
|
||||
const stored = codeStore.get(`contract-${data.token}`)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.verifyCode) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
}
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const signEvidence = JSON.stringify({
|
||||
ip: req.ip,
|
||||
userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
await prisma.laborContract.update({
|
||||
where: { id: link.contractId },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}` },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
|
||||
})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
@@ -216,6 +300,28 @@ router.get('/onboarding/:token', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 撤回入职链接(HR 端调用,需要认证)
|
||||
router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||||
}
|
||||
if (link.status !== 'PENDING') {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: { message: '入职链接已撤回' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取合同确认信息(通过 token)
|
||||
router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
try {
|
||||
@@ -245,4 +351,75 @@ router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 重发合同确认链接(HR 端调用,需要认证)
|
||||
router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||||
}
|
||||
if (link.status === 'CONFIRMED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } })
|
||||
}
|
||||
// 生成新 token 并延长过期时间
|
||||
const crypto = await import('crypto')
|
||||
const newToken = crypto.randomUUID()
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
token: newToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'UNCONFIRMED',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职文件上传
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding')
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
|
||||
|
||||
const onboardingUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (allowed.includes(ext)) cb(null, true)
|
||||
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
|
||||
},
|
||||
})
|
||||
|
||||
router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||||
}
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
fs.unlinkSync(req.file.path)
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
const fileType = (req.body.fileType as string) || 'OTHER'
|
||||
const fileUrl = `/uploads/onboarding/${req.file.filename}`
|
||||
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -11,6 +11,7 @@ const updateUserSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(['ADMIN', 'HR', 'VIEWER']).optional(),
|
||||
})
|
||||
|
||||
const createUserSchema = z.object({
|
||||
@@ -56,7 +57,7 @@ router.get('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, createdAt: true },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true, createdAt: true, lastLoginAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: users })
|
||||
@@ -97,7 +98,7 @@ router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: data,
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
@@ -118,4 +119,70 @@ router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 禁用/启用用户
|
||||
router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } })
|
||||
}
|
||||
const existing = await prisma.user.findUnique({ where: { id: req.params.id } })
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } })
|
||||
}
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { disabled: !existing.disabled },
|
||||
select: { id: true, name: true, disabled: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 切换套餐
|
||||
router.put('/plan', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' }
|
||||
if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的套餐' } })
|
||||
}
|
||||
const maxEmployees = plan === 'FREE' ? 10 : plan === 'PRO' ? 100 : 999999
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.user!.orgId },
|
||||
data: { plan, maxEmployees },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 用量统计
|
||||
router.get('/usage', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const [employeeCount, aiConversations, contracts] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId } }),
|
||||
prisma.aIConversation.count({ where: { orgId } }),
|
||||
prisma.laborContract.count({ where: { orgId } }),
|
||||
])
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { plan: true, maxEmployees: true } })
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: org?.plan || 'FREE',
|
||||
maxEmployees: org?.maxEmployees || 10,
|
||||
employeeCount,
|
||||
aiConversations,
|
||||
contracts,
|
||||
employeeUsage: `${employeeCount}/${org?.maxEmployees || 10}`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -29,4 +29,9 @@ export const onboardingSchema = z.object({
|
||||
export const contractConfirmSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
agreed: z.boolean().refine((v) => v === true, '请勾选确认签署'),
|
||||
verifyCode: z.string().length(6, '验证码为6位数字'),
|
||||
})
|
||||
|
||||
export const contractSendCodeSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { searchKnowledge } from './rag.service'
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY || ''
|
||||
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
|
||||
const client = new OpenAI({ apiKey, baseURL })
|
||||
const client = new OpenAI({ apiKey, baseURL, timeout: 30 * 1000, maxRetries: 1 })
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function* chatStream(messages: { role: 'user' | 'assistant'; conten
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewContract(contractText: string) {
|
||||
export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> {
|
||||
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
|
||||
|
||||
合同文本:
|
||||
@@ -108,7 +108,28 @@ ${contractText}
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
const text = response.choices[0]?.message?.content || ''
|
||||
|
||||
// 解析结构化数据
|
||||
const riskItems: { level: string; title: string; description: string; suggestion: string }[] = []
|
||||
const riskRegex = /(🔴|🟡|🟢)\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]/g
|
||||
let match
|
||||
while ((match = riskRegex.exec(text)) !== null) {
|
||||
riskItems.push({
|
||||
level: match[1] === '🔴' ? 'RED' : match[1] === '🟡' ? 'YELLOW' : 'GREEN',
|
||||
title: match[2],
|
||||
description: match[3],
|
||||
suggestion: match[4],
|
||||
})
|
||||
}
|
||||
|
||||
const scoreMatch = text.match(/【合规评分】\s*(\d+)\s*\/\s*100/)
|
||||
const score = scoreMatch ? parseInt(scoreMatch[1]) : 0
|
||||
|
||||
const summaryMatch = text.match(/【总体建议】\s*([\s\S]*?)(?:$|$)/)
|
||||
const summary = summaryMatch ? summaryMatch[1].trim() : ''
|
||||
|
||||
return { text, structured: { riskItems, score, summary } }
|
||||
}
|
||||
|
||||
export async function matchCase(scenario: string) {
|
||||
|
||||
@@ -53,6 +53,10 @@ export async function login(phone: string, password: string) {
|
||||
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用,请联系管理员' }
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
|
||||
@@ -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