86e5526a83
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
212 lines
8.2 KiB
TypeScript
212 lines
8.2 KiB
TypeScript
import { Router, Response, NextFunction } from 'express'
|
||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||
import prisma from '../lib/prisma'
|
||
|
||
const router = Router()
|
||
|
||
// 提取变量名
|
||
function extractVariables(content: string): string[] {
|
||
const matches = content.match(/\{\{(\w+)\}\}/g) || []
|
||
return [...new Set(matches.map(m => m.replace(/\{\{|\}\}/g, '')))]
|
||
}
|
||
|
||
// 列表
|
||
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { category, search } = req.query
|
||
const page = parseInt(req.query.page as string) || 1
|
||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
|
||
const where: any = { orgId: req.user!.orgId, status: 'ACTIVE' }
|
||
if (category) where.category = category
|
||
if (search) where.name = { contains: String(search) }
|
||
const [items, total] = await Promise.all([
|
||
(prisma as any).enterpriseTemplate.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
}),
|
||
(prisma as any).enterpriseTemplate.count({ where }),
|
||
])
|
||
res.json({ success: true, data: { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 新建
|
||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { name, category, description, content } = req.body
|
||
if (!name || !category || !content) {
|
||
return res.status(400).json({ success: false, error: { code: 'MISSING_FIELDS', message: '名称、分类、内容为必填' } })
|
||
}
|
||
const variables = extractVariables(content)
|
||
const template = await (prisma as any).enterpriseTemplate.create({
|
||
data: {
|
||
orgId: req.user!.orgId,
|
||
name,
|
||
category,
|
||
description: description || null,
|
||
content,
|
||
variables,
|
||
createdBy: req.user!.id,
|
||
},
|
||
})
|
||
res.json({ success: true, data: template })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 详情
|
||
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const template = await (prisma as any).enterpriseTemplate.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!template) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||
}
|
||
res.json({ success: true, data: template })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 更新
|
||
router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const existing = await (prisma as any).enterpriseTemplate.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!existing) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||
}
|
||
const { name, category, description, content, status } = req.body
|
||
const variables = content ? extractVariables(content) : existing.variables
|
||
const updated = await (prisma as any).enterpriseTemplate.update({
|
||
where: { id: req.params.id },
|
||
data: {
|
||
...(name !== undefined && { name }),
|
||
...(category !== undefined && { category }),
|
||
...(description !== undefined && { description }),
|
||
...(content !== undefined && { content, variables }),
|
||
...(status !== undefined && { status }),
|
||
},
|
||
})
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 删除
|
||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const existing = await (prisma as any).enterpriseTemplate.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!existing) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||
}
|
||
await (prisma as any).enterpriseTemplate.delete({ where: { id: req.params.id } })
|
||
res.json({ success: true, data: { message: '已删除' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 渲染
|
||
router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const template = await (prisma as any).enterpriseTemplate.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!template) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||
}
|
||
const { variables } = req.body as { variables: Record<string, string> }
|
||
let content = template.content
|
||
for (const [key, value] of Object.entries(variables || {})) {
|
||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||
}
|
||
res.json({ success: true, data: { content } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 下载 Word
|
||
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const template = await (prisma as any).enterpriseTemplate.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!template) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||
}
|
||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||
let content = template.content
|
||
const variables: Record<string, string> = {}
|
||
for (const [key, value] of Object.entries(req.query)) {
|
||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||
}
|
||
if (Object.keys(variables).length > 0) {
|
||
for (const [key, value] of Object.entries(variables)) {
|
||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||
}
|
||
}
|
||
// 将纯文本转换为HTML段落,使Word样式生效
|
||
const textToHtml = (text: string): string => {
|
||
// 如果内容已包含HTML标签,直接返回
|
||
if (/<[a-z][\s\S]*>/i.test(text)) return text
|
||
const lines = text.split(/\n/)
|
||
let html = ''
|
||
for (const line of lines) {
|
||
const trimmed = line.trim()
|
||
if (!trimmed) {
|
||
html += '<p style="text-indent:0"> </p>'
|
||
continue
|
||
}
|
||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||
html += `<h3>${trimmed}</h3>`
|
||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||
html += `<h1>${trimmed}</h1>`
|
||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||
html += `<p class="sign">${trimmed}</p>`
|
||
} else {
|
||
html += `<p>${trimmed}</p>`
|
||
}
|
||
}
|
||
return html
|
||
}
|
||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||
<!--[if gte mso 9]><xml>
|
||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||
</xml><![endif]-->
|
||
<style>
|
||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||
</style></head>
|
||
<body>${textToHtml(content)}</body></html>`
|
||
const encoded = encodeURIComponent(template.name + '.doc')
|
||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||
res.send(htmlContent)
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
export default router
|