Files
TurboHR/backend/src/routes/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

119 lines
5.1 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 { getAllTemplates, getTemplateById, getTemplatesByCategory, renderTemplate } from '../services/template.service'
const router = Router()
/** 获取所有模板列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const category = req.query.category as string | undefined
const templates = category ? getTemplatesByCategory(category) : getAllTemplates()
res.json({ success: true, data: templates })
} catch (err) {
next(err)
}
})
/** 获取模板详情 */
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const template = getTemplateById(req.params.id)
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.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { variables } = req.body as { variables: Record<string, string> }
const content = renderTemplate(req.params.id, variables || {})
if (!content) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
res.json({ success: true, data: { content } })
} catch (err) {
next(err)
}
})
/** 下载模板(Word .doc 格式,支持变量替换) */
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const template = getTemplateById(req.params.id)
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx
const variables: Record<string, string> = {}
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string' && key !== 'token') variables[key] = value
}
let content = template.content
if (Object.keys(variables).length > 0) {
content = renderTemplate(req.params.id, variables) || content
}
// 将纯文本转换为HTML段落,使Word样式生效
const textToHtml = (text: string): string => {
const lines = text.split(/\n/)
let html = ''
let inTable = false
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
if (inTable) { html += '</table>'; inTable = false }
html += '<p style="text-indent:0">&nbsp;</p>'
continue
}
// 标题检测:以"第X条"开头的行作为小标题
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<h3>${trimmed}</h3>`
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<h1>${trimmed}</h1>`
} else if (/^(甲方|乙方)(盖章|签字)/.test(trimmed) || /^日期[:]/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<p class="sign">${trimmed}</p>`
} else {
if (inTable) { html += '</table>'; inTable = false }
html += `<p>${trimmed}</p>`
}
}
if (inTable) html += '</table>'
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