f523f84c18
为以下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>
593 lines
25 KiB
TypeScript
593 lines
25 KiB
TypeScript
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>
|
||
)
|
||
}
|