Files
TurboHR/frontend/src/pages/Templates.tsx
T
selfrelease f523f84c18 ux: 全部页面添加操作说明 PageGuide
为以下17个缺少操作说明的页面添加 PageGuide 组件:
- OrgChart: 组织架构管理
- SupportDashboard: 客服工作台
- Money: 薪酬管理
- AIAssistant: AI 智能助手
- Settings: 系统设置
- Calendar: 人事日历
- Templates: 模板管理
- AuditLog: 审计日志
- Notifications: 通知中心
- MedicalPeriodCalculator: 医疗期计算器
- HealthCheck: 用工健康检查
- AnnualValueReport: 年度价值报表
- CompanyFiles: 公司文件管理
- LeaveApproval: 请假审批
- TrainingRecords: 培训记录
- PerformanceRecords: 绩效记录
- DisciplinaryRecords: 违纪记录

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:26:02 +08:00

593 lines
25 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 { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { templatesApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import mammoth from 'mammoth'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import { useConfirm } from '../hooks/useConfirm'
import PageGuide from '../components/ui/PageGuide'
const CATEGORY_LABELS: Record<string, string> = {
CONTRACT: '合同',
RULES: '规章制度',
NOTICE: '通知',
AGREEMENT: '协议',
OTHER: '其他',
}
const VARIABLE_LABELS: Record<string, string> = {
companyName: '公司名称',
employeeName: '员工姓名',
idCard: '证件号码',
address: '地址',
startDate: '开始日期',
position: '岗位',
endDate: '结束日期',
terminationDate: '解除日期',
publishDate: '公示日期',
effectiveDate: '生效日期',
meetingDate: '会议日期',
meetingLocation: '会议地点',
policyTitle: '制度名称',
department: '部门',
violationDate: '违纪日期',
violationDescription: '违纪描述',
probationEndDate: '试用期截止',
regularDate: '转正日期',
contractEndDate: '合同到期日',
salary: '工资',
phone: '手机号',
hireDate: '入职日期',
gender: '性别',
birthDate: '出生日期',
bankAccount: '银行账号',
bankName: '开户银行',
emergencyContact: '紧急联系人',
emergencyPhone: '紧急联系电话',
workYears: '工作年限',
compAmount: '补偿金额',
noticeDate: '通知日期',
reason: '原因',
overtimeHours: '加班时长',
overtimePay: '加班费',
socialInsBase: '社保基数',
housingFundBase: '公积金基数',
compensation: '经济补偿',
lastWorkDay: '最后工作日',
socialInsEndMonth: '社保截止月',
housingFundEndMonth: '公积金截止月',
probationMonths: '试用期月数',
monthlySalary: '月薪',
workplace: '工作地点',
disciplineType: '处分类型',
policyBasis: '制度依据',
}
/**
* 用工文本模板库页面
*/
export default function Templates() {
const [tab, setTab] = useState<'system' | 'enterprise'>('system')
return (
<div className="space-y-3">
<PageGuide>使</PageGuide>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
{/* Tab 切换 */}
<div className="flex gap-2">
<button
onClick={() => setTab('system')}
className={`px-4 py-1.5 text-sm rounded-lg ${tab === 'system' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
</button>
<button
onClick={() => setTab('enterprise')}
className={`px-4 py-1.5 text-sm rounded-lg ${tab === 'enterprise' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Building2 className="w-3.5 h-3.5 inline mr-1" />
</button>
</div>
{tab === 'system' ? <SystemTemplates /> : <EnterpriseTemplates />}
</div>
)
}
function SystemTemplates() {
const [category, setCategory] = useState<string>('')
const [selected, setSelected] = useState<any>(null)
const [rendered, setRendered] = useState<string>('')
const [variables, setVariables] = useState<Record<string, string>>({})
const { data: list, isLoading } = useQuery<any>({
queryKey: ['templates', category],
queryFn: async () => {
return await templatesApi.list(category || undefined)
},
})
const { data: detail } = useQuery<any>({
queryKey: ['template-detail', selected?.id],
queryFn: async () => {
return await templatesApi.detail(selected.id)
},
enabled: !!selected,
})
const handleRender = async () => {
if (!selected) return
try {
const res = await templatesApi.render(selected.id, variables) as any
setRendered(res.content)
} catch (err: any) {
toast.error('渲染失败')
}
}
const [showHelp, setShowHelp] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(rendered)
toast.success('已复制到剪贴板')
}
const handleDownloadWord = () => {
if (!selected) return
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const query = new URLSearchParams()
query.set('token', token || '')
for (const [k, v] of Object.entries(variables)) {
if (v) query.append(k, v)
}
const a = document.createElement('a')
a.href = `${baseURL}/templates/${selected.id}/download?${query.toString()}`
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleCopyAsNew = () => {
const text = rendered || detail?.content || ''
navigator.clipboard.writeText(text)
toast.success('模板内容已复制,可粘贴到 Word 中编辑使用')
}
return (
<div className="space-y-3">
<p className="text-sm text-gray-500"></p>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowHelp(!showHelp)}>
<HelpCircle className="w-3.5 h-3.5 mr-1" />使
</Button>
</div>
{showHelp && (
<Card className="bg-blue-50/50">
<div className="space-y-2 text-xs text-gray-600">
<div className="flex items-center gap-1.5 font-medium text-gray-700"><BookOpen className="w-3.5 h-3.5" />使</div>
<div>1. ///</div>
<div>2. </div>
<div>3. Word .doc </div>
<div>4. Word </div>
<div>5. <code className="px-1 bg-gray-100 rounded">{'{{变量名}}'}</code> </div>
</div>
</Card>
)}
<div className="flex gap-2">
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1 text-xs rounded-lg ${category === c ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{c === '' ? '全部' : CATEGORY_LABELS[c]}
</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无模板" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{list.map((t: any) => (
<Card key={t.id} className="hover:shadow-md transition-shadow cursor-pointer" >
<div onClick={() => { setSelected(t); setRendered(''); setVariables({}) }}>
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded text-xs bg-primary/10 text-primary">{CATEGORY_LABELS[t.category]}</span>
<span className="text-sm font-medium truncate">{t.name}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
{t.variables?.slice(0, 4).map((v: string) => (
<span key={v} className="px-1 py-0.5 rounded bg-gray-100">{VARIABLE_LABELS[v] || v}</span>
))}
{t.variables?.length > 4 && <span>+{t.variables.length - 4}</span>}
</div>
</div>
</Card>
))}
</div>
)}
{/* 模板详情弹窗 */}
{selected && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelected(null)}>
<Card className="max-w-3xl w-full max-h-[85vh] overflow-y-auto" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selected.name}</h2>
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
{/* 变量输入 */}
{detail?.variables && detail.variables.length > 0 && (
<div className="mb-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="grid grid-cols-2 gap-2">
{detail.variables.map((v: string) => (
<div key={v}>
<label className="text-xs text-gray-500">{VARIABLE_LABELS[v] || v}</label>
<input
value={variables[v] || ''}
onChange={e => setVariables(prev => ({ ...prev, [v]: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={`输入${VARIABLE_LABELS[v] || v}`}
/>
</div>
))}
</div>
<Button size="sm" onClick={handleRender}></Button>
</div>
)}
{/* 渲染结果 */}
{rendered ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<div className="flex gap-2">
<button onClick={handleCopyAsNew} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Copy className="w-3 h-3" />
</button>
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="w-3 h-3" /> Word
</button>
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
</div>
) : detail?.content ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<div className="flex gap-2">
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="w-3 h-3" /> Word
</button>
</div>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
</div>
) : null}
</div>
</Card>
</div>
)}
</div>
)
}
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const fileInputRef = useRef<HTMLInputElement>(null)
const [category, setCategory] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [showEdit, setShowEdit] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
const [selected, setSelected] = useState<any>(null)
const [rendered, setRendered] = useState('')
const [variables, setVariables] = useState<Record<string, string>>({})
const { data: listData, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates', category, page, pageSize],
queryFn: async () => {
const params: any = { page, pageSize }
if (category) params.category = category
return await templatesApi.enterpriseList({ page, pageSize, category: category || undefined } as any)
},
})
const list = listData?.items || []
const total = listData?.total || 0
const { data: detail } = useQuery<any>({
queryKey: ['enterprise-template-detail', selected?.id],
queryFn: async () => {
return await templatesApi.enterpriseDetail(selected.id)
},
enabled: !!selected,
})
const saveMutation = useMutation({
mutationFn: async (data: any) => {
if (editItem) {
return await templatesApi.saveEnterprise(data, editItem.id)
} else {
return await templatesApi.saveEnterprise(data)
}
},
onSuccess: () => {
toast.success(editItem ? '已更新' : '已创建')
queryClient.invalidateQueries({ queryKey: ['enterprise-templates'] })
setShowEdit(false)
setEditItem(null)
setForm({ name: '', category: 'CONTRACT', description: '', content: '' })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await templatesApi.removeEnterprise(id)
},
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['enterprise-templates'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const handleRender = async () => {
if (!selected) return
try {
const res = await templatesApi.renderEnterprise(selected.id, variables) as any
setRendered(res.content)
} catch {
toast.error('渲染失败')
}
}
const handleDownloadWord = () => {
if (!selected) return
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const query = new URLSearchParams()
query.set('token', token || '')
for (const [k, v] of Object.entries(variables)) {
if (v) query.append(k, v)
}
const a = document.createElement('a')
a.href = `${baseURL}/enterprise-templates/${selected.id}/download?${query.toString()}`
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleEdit = (item: any) => {
setEditItem(item)
setForm({ name: item.name, category: item.category, description: item.description || '', content: item.content })
setShowEdit(true)
}
const handleAdd = () => {
setEditItem(null)
setForm({ name: '', category: 'CONTRACT', description: '', content: '' })
setShowEdit(true)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500"> Word </p>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex gap-2">
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={`px-3 py-1 text-xs rounded-lg ${category === c ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{c === '' ? '全部' : CATEGORY_LABELS[c]}
</button>
))}
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
<EmptyState title="暂无企业模板" description="点击「新建模板」创建您的第一个企业文本模板" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{list.map((t: any) => (
<Card key={t.id} className="hover:shadow-md transition-shadow">
<div onClick={() => { setSelected(t); setRendered(''); setVariables({}) }} className="cursor-pointer">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded text-xs bg-primary/10 text-primary">{CATEGORY_LABELS[t.category]}</span>
<span className="text-sm font-medium truncate flex-1">{t.name}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
{t.variables?.slice(0, 4).map((v: string) => (
<span key={v} className="px-1 py-0.5 rounded bg-gray-100">{VARIABLE_LABELS[v] || v}</span>
))}
{t.variables?.length > 4 && <span>+{t.variables.length - 4}</span>}
</div>
</div>
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-100">
<button onClick={() => handleEdit(t)} className="flex items-center gap-1 text-xs text-gray-500 hover:text-primary">
<Edit className="w-3 h-3" />
</button>
<button
onClick={async () => { if (await confirm({ title: '删除模板', message: '确认删除?' })) deleteMutation.mutate(t.id) }}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</Card>
))}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
/>
{/* 编辑弹窗 */}
<Modal open={showEdit} onClose={() => setShowEdit(false)} title={editItem ? '编辑模板' : '新建模板'} size="lg">
<div className="space-y-3">
<div>
<Label></Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:员工保密协议" />
</div>
<div>
<Label></Label>
<Select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="简要描述模板用途" />
</div>
<div>
<Label></Label>
<div className="flex items-center gap-2 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setForm({ ...form, content: result.value })
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[200px] font-mono"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="输入模板内容,使用 {{变量名}} 作为变量占位符,如 {{employeeName}}、{{companyName}}"
/>
</div>
<div className="text-xs text-gray-500">
<code className="px-1 bg-gray-100 rounded">{'{{变量名}}'}</code> <code className="px-1 bg-gray-100 rounded">{'{{employeeName}}'}</code><code className="px-1 bg-gray-100 rounded">{'{{companyName}}'}</code>
</div>
<Button onClick={() => saveMutation.mutate(form)} disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</Modal>
{/* 详情弹窗 */}
{selected && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelected(null)}>
<Card className="max-w-3xl w-full max-h-[85vh] overflow-y-auto">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selected.name}</h2>
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
{detail?.variables && detail.variables.length > 0 && (
<div className="mb-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
<div className="grid grid-cols-2 gap-2">
{detail.variables.map((v: string) => (
<div key={v}>
<label className="text-xs text-gray-500">{VARIABLE_LABELS[v] || v}</label>
<input
value={variables[v] || ''}
onChange={e => setVariables(prev => ({ ...prev, [v]: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={`输入${VARIABLE_LABELS[v] || v}`}
/>
</div>
))}
</div>
<Button size="sm" onClick={handleRender}></Button>
</div>
)}
{rendered ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<div className="flex gap-2">
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="w-3 h-3" /> Word
</button>
<button onClick={() => { navigator.clipboard.writeText(rendered); toast.success('已复制') }} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
</div>
) : detail?.content ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600"></span>
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="w-3 h-3" /> Word
</button>
</div>
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
</div>
) : null}
</div>
</Card>
</div>
)}
</div>
)
}