feat: 实现20260730优化方案全部功能
- AI文件审查:.docx上传提取文本,支持多种文档类型 - 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程 - 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word - 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面 - 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤 - 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作 - Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型 - 前后端编译验证全部通过
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle } from 'lucide-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 api from '../lib/api'
|
||||
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'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -68,6 +70,37 @@ const VARIABLE_LABELS: Record<string, string> = {
|
||||
* 用工文本模板库页面
|
||||
*/
|
||||
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>('')
|
||||
@@ -137,10 +170,6 @@ export default function Templates() {
|
||||
|
||||
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>
|
||||
<p className="text-sm text-gray-500">合同、制度、通知等常用文本模板,支持变量替换</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -270,3 +299,264 @@ export default function Templates() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnterpriseTemplates() {
|
||||
const queryClient = useQueryClient()
|
||||
const [category, setCategory] = useState<string>('')
|
||||
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: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['enterprise-templates', category],
|
||||
queryFn: async () => {
|
||||
const params = category ? `?category=${category}` : ''
|
||||
const res = await api.get(`/enterprise-templates${params}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: detail } = useQuery<any>({
|
||||
queryKey: ['enterprise-template-detail', selected?.id],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/enterprise-templates/${selected.id}`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!selected,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
if (editItem) {
|
||||
const res = await api.put(`/enterprise-templates/${editItem.id}`, data) as any
|
||||
return res.data
|
||||
} else {
|
||||
const res = await api.post('/enterprise-templates', data) as any
|
||||
return res.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 api.delete(`/enterprise-templates/${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 api.post(`/enterprise-templates/${selected.id}/render`, { variables }) as any
|
||||
setRendered(res.data.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>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user