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:
freedakgmail
2026-07-30 10:21:22 +08:00
parent 38b8849332
commit 42e0c650a4
24 changed files with 3639 additions and 35 deletions
+296 -6
View File
@@ -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>
)
}