Files
TurboHR/backend/src/routes/enterprise-template.routes.ts
T
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题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+自动修复历史哈希)
2026-08-11 22:02:19 +08:00

212 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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">&nbsp;</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