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:
freedakgmail
2026-07-24 07:58:25 +08:00
parent f1c72f3eb0
commit e01bad4815
17 changed files with 1721 additions and 141 deletions
+17
View File
@@ -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")
}
+80 -9
View File
@@ -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
+140 -33
View File
@@ -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)
}
+202 -11
View File
@@ -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', '调薪原因': '年度调薪' }]
+35
View File
@@ -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
+182 -5
View File
@@ -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
+69 -2
View File
@@ -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
+5
View File
@@ -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'),
})
+24 -3
View File
@@ -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) {
+4
View File
@@ -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() },
+283 -10
View File
@@ -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>
)
}
+8 -1
View File
@@ -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
View File
@@ -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>
+47 -3
View File
@@ -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}
+33 -4
View File
@@ -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>
+94 -3
View File
@@ -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>
+69 -2
View File
@@ -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>