Files
TurboHR/frontend/src/pages/Templates.tsx
T
selfrelease 16f22e6622 refactor: 全量迁移前端 API 调用到统一 api-services 服务层
- 新建 api-services-raw.ts 导出原始 axios 方法供特殊端点使用
- 完成 api-services.ts 全领域覆盖(auth/employee/roster/dashboard/attendance/payroll/socialInsurance/commercialInsurance/termination/policies/evidence/audit/calendar/companyFiles/notifications/settings/ai/platform/portal/survey/search)
- 迁移所有 47+ 页面文件:pages/、pages/roster/、pages/portal/、pages/platform/、pages/auth/、pages/dashboard/、pages/compliance/
- 移除所有直接 import api from '../../lib/api' 引用
- 修复 Termination.tsx / WorkProcess.tsx 中 string|null 类型错误
- 修复 SocialInsurance.tsx 中 api-services-raw delete 导入名
- 修复 PlatformLogin.tsx 变量遮蔽问题
- tsc --noEmit 零错误,vite build 成功
2026-08-01 16:13:19 +08:00

569 lines
24 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 } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } 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 Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
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">
<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 = async () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
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 = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载 Word 文档')
} catch {
toast.error('下载失败')
}
}
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 [category, setCategory] = useState<string>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
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 = async () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
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 = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载')
} catch {
toast.error('下载失败')
}
}
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={() => { if (confirm('确认删除?')) 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={(s) => { setPageSize(s); 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>
<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>
)
}