import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import {
CleanupIcon, DownloadIcon, RefreshIcon, CheckIcon, CloseIcon, PencilIcon, SparkIcon, ContractIcon
} from '../components/Icons'
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
const JOB_STATUS = {
queued: { label: '排队中', tone: 'mute' },
parsing: { label: '解析中', tone: 'warn' },
pending_review: { label: '待复核', tone: 'warn' },
reviewed: { label: '已复核', tone: 'ok' },
parse_failed: { label: '解析失败', tone: 'danger' }
}
const ITEM_STATUS = {
pending_review: { label: '待复核', tone: 'warn' },
approved: { label: '已通过', tone: 'ok' },
rejected: { label: '已驳回', tone: 'danger' }
}
/** 一条明细的状态显示:入库过的优先显示「已入库」——它比 approved 更具体 */
function itemStatusMeta(item) {
if (item.committed_at) return { label: '已入库', tone: 'ok' }
return ITEM_STATUS[item.status] || { label: item.status, tone: 'mute' }
}
function formatMoment(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtSize(bytes) {
if (!Number.isFinite(bytes)) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
/** 解析结果表头认了哪几列 —— 规则引擎唯一能自证的现场,认错了就看这里 */
function ColumnMapPanel({ raw }) {
const map = useMemo(() => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
const entries = Object.entries(parsed || {})
return entries.length ? entries : null
} catch {
return null
}
}, [raw])
if (!map) {
return
没有认出任一列 —— 可以用「AI 解析」或逐条手工补录。
}
return (
{map.map(([field, hit]) => (
{field}
←
{hit.header}
{hit.score} 分
))}
)
}
/** 单行复核编辑器:点「修正」后就地变成可编辑,避免为了改一个价格再开一层弹窗 */
function ReviewRow({ item, selected, onToggle, onReviewed, onCommitted, thumbs }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
sku: item.sku || '',
product_name: item.product_name || '',
spec: item.spec || '',
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
currency: item.currency || '',
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
review_note: item.review_note || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setForm({
sku: item.sku || '',
product_name: item.product_name || '',
spec: item.spec || '',
purchase_price: item.purchase_price === null || item.purchase_price === undefined ? '' : String(item.purchase_price),
currency: item.currency || '',
quantity: item.quantity === null || item.quantity === undefined ? '' : String(item.quantity),
moq: item.moq === null || item.moq === undefined ? '' : String(item.moq),
review_note: item.review_note || ''
})
setEditing(false)
setError('')
}, [item])
const payload = status => ({
status,
review_note: form.review_note.trim() || null,
sku: form.sku.trim() || null,
product_name: form.product_name.trim() || null,
spec: form.spec.trim() || null,
purchase_price: form.purchase_price === '' ? null : Number(form.purchase_price),
currency: form.currency.trim() || null,
quantity: form.quantity === '' ? null : Number(form.quantity),
moq: form.moq === '' ? null : Number(form.moq)
})
/** 只改状态(驳回 / 放回待复核) */
const send = async (status) => {
setSaving(true)
setError('')
try {
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload(status))
setEditing(false)
onReviewed()
} catch (reviewError) {
setError(reviewError?.response?.data?.error || '操作失败,请稍后重试')
} finally {
setSaving(false)
}
}
/** 保存修正并直接入库:先把改过的字段存回队列,再走入库接口 */
const saveAndCommit = async () => {
setSaving(true)
setError('')
try {
await axios.patch(`/api/contracts/import-items/${item.id}/review`, payload('pending_review'))
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
setEditing(false)
onCommitted()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setSaving(false)
}
}
/** 不改任何字段,直接把这一条入库 */
const commitOne = async () => {
setSaving(true)
setError('')
try {
await axios.post('/api/contracts/import-items/commit', { ids: [item.id] })
onCommitted()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setSaving(false)
}
}
const statusMeta = itemStatusMeta(item)
const committed = Boolean(item.committed_at)
const renamed = committed && item.product_sku && item.sku && item.product_sku !== item.sku
if (editing) {
return (
|
{item.filename}{item.row_no ? `#${item.row_no}` : ''} |
setForm({ ...form, sku: event.target.value })} />
|
setForm({ ...form, product_name: event.target.value })} />
|
setForm({ ...form, spec: event.target.value })} />
|
setForm({ ...form, purchase_price: event.target.value })} />
|
setForm({ ...form, currency: event.target.value })} />
|
setForm({ ...form, quantity: event.target.value })} />
|
setForm({ ...form, moq: event.target.value })} />
|
setForm({ ...form, review_note: event.target.value })} />
{error && {error} }
|
|
)
}
return (
|
onToggle(item.id)}
aria-label={`选择 ${item.sku || item.filename + '#' + item.row_no}`}
/>
|
{item.filename}{item.row_no ? `#${item.row_no}` : ''}
|
{item.sku ? (
{item.sku}
{renamed && → {item.product_sku}}
) : 未识别}
|
{item.product_name || 未识别} |
{item.spec || —} |
{item.purchase_price === null || item.purchase_price === undefined
? —
: item.purchase_price}
|
{item.currency || —} |
{item.quantity === null || item.quantity === undefined ? '—' : item.quantity} |
{item.moq === null || item.moq === undefined ? '—' : item.moq} |
{statusMeta.label}
{item.parse_source === 'ai' && AI}
|
{item.review_note || —} |
|
)
}
function ContractCleanup() {
const [jobs, setJobs] = useState([])
const [activeJobId, setActiveJobId] = useState(null)
const [jobDetail, setJobDetail] = useState(null)
const [loadingJobs, setLoadingJobs] = useState(true)
const [loadingDetail, setLoadingDetail] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [dragging, setDragging] = useState(false)
const [statusFilter, setStatusFilter] = useState('')
const [selected, setSelected] = useState(() => new Set())
const [committing, setCommitting] = useState(false)
const [aiRunning, setAiRunning] = useState(false)
const [commitReport, setCommitReport] = useState(null)
const [originals, setOriginals] = useState(null)
const [showAll, setShowAll] = useState(false)
const fileInputRef = useRef(null)
const headPickRef = useRef(null)
const PAGE_LIMIT = 300
const loadJobs = useCallback(async (preferId) => {
setLoadingJobs(true)
try {
const { data } = await axios.get('/api/contracts/import-jobs')
const rows = Array.isArray(data) ? data : []
setJobs(rows)
setActiveJobId(current => {
if (preferId) return preferId
if (current && rows.some(row => row.id === current)) return current
return rows[0]?.id ?? null
})
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取导入任务失败')
} finally {
setLoadingJobs(false)
}
}, [])
useEffect(() => { loadJobs() }, [loadJobs])
const loadDetail = useCallback(async (jobId) => {
if (!jobId) { setJobDetail(null); return }
setLoadingDetail(true)
try {
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}`)
setJobDetail(data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取任务明细失败')
setJobDetail(null)
} finally {
setLoadingDetail(false)
}
}, [])
useEffect(() => {
loadDetail(activeJobId)
setSelected(new Set())
setCommitReport(null)
setStatusFilter('')
setShowAll(false)
}, [activeJobId, loadDetail])
/* 原件只在服务器留 7 天,所以「还存在哪些原件」得单独问一次接口 */
const loadOriginals = useCallback(async (jobId) => {
if (!jobId) { setOriginals(null); return }
try {
const { data } = await axios.get(`/api/contracts/import-jobs/${jobId}/files`)
setOriginals(data)
} catch {
setOriginals(null)
}
}, [])
useEffect(() => { loadOriginals(activeJobId) }, [activeJobId, loadOriginals])
const upload = async (fileList) => {
const files = [...(fileList || [])]
if (!files.length) return
setUploading(true)
setError('')
setNotice('')
try {
const form = new FormData()
files.forEach(file => form.append('files', file))
const { data } = await axios.post('/api/contracts/import', form)
const jobId = data?.job?.id
await loadJobs(jobId)
if (jobId) { await loadDetail(jobId); loadOriginals(jobId) }
setNotice(`${files.length} 个文件已解析。${data?.message || ''}`)
} catch (uploadError) {
setError(uploadError?.response?.data?.error || '上传失败,请稍后重试')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const onDrop = (event) => {
event.preventDefault()
setDragging(false)
upload(event.dataTransfer?.files)
}
const refreshAfterReview = useCallback(async () => {
await loadDetail(activeJobId)
// 复核完最后一条,任务状态会从 pending_review 变 reviewed,列表也要跟着刷新
await loadJobs(activeJobId)
}, [activeJobId, loadDetail, loadJobs])
const refreshAfterCommit = useCallback(async () => {
setSelected(new Set())
await loadDetail(activeJobId)
await loadJobs(activeJobId)
}, [activeJobId, loadDetail, loadJobs])
const items = jobDetail?.items || []
const filteredItems = useMemo(
() => (statusFilter ? items.filter(item => item.status === statusFilter) : items),
[items, statusFilter]
)
const shownItems = useMemo(
() => (showAll ? filteredItems : filteredItems.slice(0, PAGE_LIMIT)),
[filteredItems, showAll]
)
const counts = useMemo(() => {
const result = { total: items.length, pending_review: 0, approved: 0, rejected: 0, committed: 0 }
items.forEach(item => {
result[item.status] = (result[item.status] || 0) + 1
if (item.committed_at) result.committed += 1
})
return result
}, [items])
const thumbs = useSkuThumbs(items.map(item => item.sku).filter(Boolean))
const pickableIds = useMemo(
() => filteredItems.filter(item => !item.committed_at).map(item => item.id),
[filteredItems]
)
const allPicked = pickableIds.length > 0 && pickableIds.every(id => selected.has(id))
const somePicked = pickableIds.some(id => selected.has(id))
useEffect(() => {
if (headPickRef.current) headPickRef.current.indeterminate = somePicked && !allPicked
}, [somePicked, allPicked])
const togglePick = (id) => {
setSelected(current => {
const next = new Set(current)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const pickAll = () => setSelected(new Set(pickableIds))
const invertPick = () => setSelected(current => {
const next = new Set()
pickableIds.forEach(id => { if (!current.has(id)) next.add(id) })
return next
})
const clearPick = () => setSelected(new Set())
const commitSelected = async () => {
const ids = [...selected]
if (!ids.length) { setError('请先勾选要入库的明细'); return }
if (!window.confirm(`把勾选的 ${ids.length} 条写进产品库?\n\n已存在的货号会更新采购价并记入调价历史,库里没有的货号会新建。`)) return
setCommitting(true)
setError('')
setNotice('')
setCommitReport(null)
try {
const { data } = await axios.post('/api/contracts/import-items/commit', { ids })
setCommitReport(data)
setNotice(data?.message || '入库完成')
await refreshAfterCommit()
} catch (commitError) {
setError(commitError?.response?.data?.error || '入库失败,请稍后重试')
} finally {
setCommitting(false)
}
}
const runAiParse = async () => {
if (!activeJobId) return
if (!window.confirm('用模型重新解析这个任务?\n\n原来是规则解析出来的「待复核」行会被替换掉(已经复核或已入库的行不受影响)。')) return
setAiRunning(true)
setError('')
setNotice('')
try {
const { data } = await axios.post(`/api/contracts/import-jobs/${activeJobId}/ai-parse`)
setNotice(data?.message || 'AI 解析完成')
await refreshAfterCommit()
} catch (aiError) {
setError(aiError?.response?.data?.error || 'AI 解析失败')
} finally {
setAiRunning(false)
}
}
const downloadOriginal = async (name) => {
try {
const response = await axios.get(`/api/contracts/import-jobs/${activeJobId}/file`, {
params: { name }, responseType: 'blob'
})
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = name
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (downloadError) {
setError('原件下载失败')
}
}
const activeJob = jobDetail?.job
const jobMeta = activeJob ? (JOB_STATUS[activeJob.status] || { label: activeJob.status, tone: 'mute' }) : null
return (
合同清洗中心
供应商合同批量导入 → 按字符规则认列(不认行列位置)→ 人工审核入库。
规则认不出来的,可以交给 AI 解析。原件只在服务器保留 7 天后自动清理。
{error &&
{error}
}
{notice &&
{notice}
}
{commitReport?.skipped?.length > 0 && (
有 {commitReport.skipped.length} 条没入库:
{commitReport.skipped.slice(0, 8).map(row => (
- #{row.id}{row.sku ? `(${row.sku})` : ''}:{row.reason}
))}
{commitReport.skipped.length > 8 && - …还有 {commitReport.skipped.length - 8} 条
}
)}
{!activeJob ? (
选一个导入任务
左边点一个任务,这里会列出逐条解析结果供复核入库。
) : loadingDetail ? (
读取明细中…
) : (
<>
{activeJob.job_no} {jobMeta.label}
解析方式 {activeJob.parse_source === 'ai' ? 'AI 模型' : '字符规则'} · 规则版本 {activeJob.rule_version} · 创建 {formatMoment(activeJob.created_at)}
{Object.entries(ITEM_STATUS).map(([value, meta]) => (
counts[value] ? (
) : null
))}
{counts.committed > 0 && (
已入库 {counts.committed}
)}
{activeJob.status === 'parse_failed' && activeJob.notes && (
上一次解析中断在:{activeJob.notes}。可以修正文件表头后重新导入。
)}
已勾选 {selected.size} 条
解析详情(认了哪几列 / 原件留存)
本次认列结果
原件留存
{originals?.files?.length ? (
{originals.files.map(file => (
))}
服务器只保留 {originals.retention_days} 天
{originals.expires_at ? `(${new Date(originals.expires_at).toLocaleDateString('zh-CN')} 起自动清理)` : ''}
) : (
{originals?.expired
? `原件已过保留期(${originals.retention_days} 天)被自动清理 —— 解析结果与已入库的价格不受影响。`
: '没有留存的原件。'}
)}
{counts.pending_review === 0 && counts.total > 0 && (
这个任务已经处理完毕{counts.committed ? `,其中 ${counts.committed} 条已入库` : ''}。
)}
{filteredItems.length === 0 ? (
{items.length ? '当前筛选下没有明细' : '这个任务没有解析出任何行'}
{items.length
? '换个状态筛选看看。'
: '规则没认出来。可以点上面的「AI 解析」让模型试一次,或点「修正」手工补录。'}
) : (
<>
{filteredItems.length > shownItems.length && (
还有 {filteredItems.length - shownItems.length} 条未显示
)}
>
)}
>
)}
)
}
export default ContractCleanup