334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
'use client'
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import { ChevronLeft, ChevronRight, Download, RefreshCw, X } from 'lucide-react'
|
||
import type { ProductArchiveChangeLogItem } from '@/lib/supply-chain-product-archive-change-log'
|
||
import { formatProductArchiveChangeSource } from '@/lib/supply-chain-product-archive-change-log'
|
||
import { useProductArchiveChangeLog } from './useProductArchiveChangeLog'
|
||
import {
|
||
ProductArchiveChangeLogFilters,
|
||
type ProductArchiveLogFilterValues,
|
||
} from './ProductArchiveChangeLogFilters'
|
||
|
||
const EMPTY_FILTERS: ProductArchiveLogFilterValues = {
|
||
actor: '',
|
||
startDate: '',
|
||
endDate: '',
|
||
content: '',
|
||
operation: '',
|
||
}
|
||
|
||
function formatTime(value: string) {
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '-'
|
||
return new Intl.DateTimeFormat('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false,
|
||
}).format(date)
|
||
}
|
||
|
||
function displayContent(log: ProductArchiveChangeLogItem) {
|
||
if (log.details.kind === 'IMPORTED') return '商品档案导入'
|
||
const field = log.details.fieldLabel || '商品档案'
|
||
const previousValue = log.details.previousValue || '-'
|
||
const nextValue = log.details.nextValue || '-'
|
||
return `${field}:${previousValue} → ${nextValue}`
|
||
}
|
||
|
||
function displayOperation(log: ProductArchiveChangeLogItem) {
|
||
if (log.details.kind === 'IMPORTED') return '导入商品档案'
|
||
return log.source === 'SYSTEM' ? '数据库推送更新' : '系统修改'
|
||
}
|
||
|
||
function csvCell(value: string) {
|
||
return `"${value.replaceAll('"', '""')}"`
|
||
}
|
||
|
||
function exportLogs(logs: ProductArchiveChangeLogItem[]) {
|
||
const rows = [
|
||
['商品编码', '操作内容', '操作', '操作人', '操作时间'],
|
||
...logs.map((log) => [
|
||
log.barcode,
|
||
displayContent(log),
|
||
displayOperation(log),
|
||
formatProductArchiveChangeSource(log.source, log.actorName, log.details),
|
||
formatTime(log.createdAt),
|
||
]),
|
||
]
|
||
const blob = new Blob([`\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], {
|
||
type: 'text/csv;charset=utf-8',
|
||
})
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = '商品修改日志.csv'
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function inDateRange(value: string, startDate: string, endDate: string) {
|
||
const timestamp = new Date(value).getTime()
|
||
if (startDate) {
|
||
const start = new Date(`${startDate}T00:00:00`).getTime()
|
||
if (timestamp < start) return false
|
||
}
|
||
if (endDate) {
|
||
const end = new Date(`${endDate}T23:59:59.999`).getTime()
|
||
if (timestamp > end) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
export function ProductArchiveChangeLogDialog({
|
||
barcode,
|
||
onClose,
|
||
}: {
|
||
barcode: string
|
||
onClose: () => void
|
||
}) {
|
||
const [page, setPage] = useState(1)
|
||
const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS)
|
||
const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS)
|
||
const { logs, pageInfo, loading, error, reload } = useProductArchiveChangeLog(barcode, page)
|
||
|
||
const actors = useMemo(
|
||
() => Array.from(new Set(logs.map((log) => formatProductArchiveChangeSource(log.source, log.actorName, log.details)))).sort(),
|
||
[logs],
|
||
)
|
||
const filteredLogs = useMemo(() => {
|
||
const contentKeyword = appliedFilters.content.trim().toLowerCase()
|
||
return logs.filter((log) => {
|
||
const actor = formatProductArchiveChangeSource(log.source, log.actorName, log.details)
|
||
const content = `${displayContent(log)} ${log.message}`.toLowerCase()
|
||
return (
|
||
(!appliedFilters.actor || actor === appliedFilters.actor) &&
|
||
(!contentKeyword || content.includes(contentKeyword)) &&
|
||
(!appliedFilters.operation || log.details.kind === appliedFilters.operation) &&
|
||
inDateRange(log.createdAt, appliedFilters.startDate, appliedFilters.endDate)
|
||
)
|
||
})
|
||
}, [appliedFilters, logs])
|
||
|
||
const updateFilter = <K extends keyof ProductArchiveLogFilterValues>(
|
||
key: K,
|
||
value: ProductArchiveLogFilterValues[K],
|
||
) => setDraftFilters((current) => ({ ...current, [key]: value }))
|
||
|
||
const resetFilters = () => {
|
||
setDraftFilters(EMPTY_FILTERS)
|
||
setAppliedFilters(EMPTY_FILTERS)
|
||
setPage(1)
|
||
}
|
||
|
||
return (
|
||
<div className="archive-log-overlay" onMouseDown={onClose}>
|
||
<section
|
||
className="archive-log-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="archive-log-title"
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
>
|
||
<header className="archive-log-header">
|
||
<div>
|
||
<p className="archive-log-eyebrow">商品档案 · 条码 {barcode}</p>
|
||
<h2 id="archive-log-title">查看商品修改日志</h2>
|
||
</div>
|
||
<div className="archive-log-header-actions">
|
||
<button type="button" onClick={() => exportLogs(filteredLogs)} title="导出当前日志" aria-label="导出当前日志">
|
||
<Download size={16} strokeWidth={1.8} />
|
||
</button>
|
||
<button type="button" onClick={onClose} title="关闭" aria-label="关闭日志">
|
||
<X size={18} strokeWidth={1.8} />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<ProductArchiveChangeLogFilters
|
||
barcode={barcode}
|
||
values={draftFilters}
|
||
actors={actors}
|
||
onChange={updateFilter}
|
||
onSearch={() => {
|
||
setAppliedFilters(draftFilters)
|
||
setPage(1)
|
||
}}
|
||
onReset={resetFilters}
|
||
/>
|
||
|
||
<div className="archive-log-table-area">
|
||
<div className="archive-log-table-wrap">
|
||
<table className="archive-log-table">
|
||
<thead>
|
||
<tr>
|
||
<th>商品编码</th>
|
||
<th>操作内容</th>
|
||
<th>操作</th>
|
||
<th>操作人</th>
|
||
<th>操作时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
<tr className="archive-log-placeholder-row">
|
||
<td colSpan={5}>正在读取日志...</td>
|
||
</tr>
|
||
) : error ? (
|
||
<tr className="archive-log-placeholder-row archive-log-error-row">
|
||
<td colSpan={5}>{error}</td>
|
||
</tr>
|
||
) : filteredLogs.length === 0 ? (
|
||
<tr className="archive-log-placeholder-row">
|
||
<td className="archive-log-barcode">{barcode}</td>
|
||
<td className="archive-log-content-cell">
|
||
{logs.length === 0 ? '暂无操作记录' : '当前筛选无匹配记录'}
|
||
</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td className="archive-log-time">-</td>
|
||
</tr>
|
||
) : (
|
||
filteredLogs.map((log) => (
|
||
<tr key={log.id}>
|
||
<td className="archive-log-barcode">{log.barcode || barcode}</td>
|
||
<td className="archive-log-content-cell">{displayContent(log)}</td>
|
||
<td>{displayOperation(log)}</td>
|
||
<td>{formatProductArchiveChangeSource(log.source, log.actorName, log.details)}</td>
|
||
<td className="archive-log-time">{formatTime(log.createdAt)}</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<footer className="archive-log-footer">
|
||
<span>共 {appliedFilters === EMPTY_FILTERS ? pageInfo.total : filteredLogs.length} 条,每页 {pageInfo.pageSize} 条</span>
|
||
<div className="archive-log-pagination">
|
||
<button type="button" disabled={loading || page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))} aria-label="上一页">
|
||
<ChevronLeft size={14} strokeWidth={1.8} />
|
||
</button>
|
||
<span>{page} / {pageInfo.totalPages}</span>
|
||
<button type="button" disabled={loading || page >= pageInfo.totalPages} onClick={() => setPage((current) => Math.min(pageInfo.totalPages, current + 1))} aria-label="下一页">
|
||
<ChevronRight size={14} strokeWidth={1.8} />
|
||
</button>
|
||
<button type="button" onClick={reload} title="刷新日志" aria-label="刷新日志">
|
||
<RefreshCw size={14} strokeWidth={1.8} />
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
</section>
|
||
|
||
<style jsx>{`
|
||
.archive-log-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 110;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 24px;
|
||
background: rgba(24, 24, 27, 0.48);
|
||
}
|
||
.archive-log-modal {
|
||
display: flex;
|
||
width: min(1140px, calc(100vw - 48px));
|
||
max-height: calc(100vh - 48px);
|
||
min-height: min(560px, calc(100vh - 48px));
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding: 24px;
|
||
overflow: hidden;
|
||
border: 1px solid #e4e4e7;
|
||
border-radius: 5px;
|
||
background: #fff;
|
||
box-shadow: 0 20px 60px rgba(24, 24, 27, 0.22);
|
||
}
|
||
.archive-log-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.archive-log-eyebrow { margin: 0 0 4px; color: #71717a; font-size: 12px; }
|
||
.archive-log-header h2 { margin: 0; color: #18181b; font-size: 18px; font-weight: 600; }
|
||
.archive-log-header-actions { display: inline-flex; align-items: center; gap: 9px; }
|
||
.archive-log-header-actions button {
|
||
display: inline-flex;
|
||
width: 30px;
|
||
height: 30px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
border: 1px solid transparent;
|
||
border-radius: 4px;
|
||
background: transparent;
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
}
|
||
.archive-log-header-actions button:hover { border-color: #d4d4d8; background: #f8fafc; color: #334155; }
|
||
.archive-log-table-area { min-height: 0; flex: 1; overflow: auto; }
|
||
.archive-log-state { display: grid; min-height: 220px; place-items: center; color: #a1a1aa; font-size: 13px; }
|
||
.archive-log-error { color: #b91c1c; }
|
||
.archive-log-table-wrap { overflow-x: auto; border: 1px solid #e4e4e7; border-radius: 4px; }
|
||
.archive-log-table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: 12px; }
|
||
.archive-log-table th {
|
||
padding: 10px 8px;
|
||
border-bottom: 1px solid #e4e4e7;
|
||
background: #f1f3f5;
|
||
color: #27272a;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
}
|
||
.archive-log-table td { padding: 10px 8px; border-bottom: 1px solid #f1f5f9; color: #334155; vertical-align: top; }
|
||
.archive-log-table tbody tr:last-child td { border-bottom: 0; }
|
||
.archive-log-table tbody tr:hover td { background: #fafafa; }
|
||
.archive-log-placeholder-row td { height: 48px; color: #94a3b8; }
|
||
.archive-log-error-row td { color: #b91c1c; }
|
||
.archive-log-barcode { color: #1e3a8a; font-family: ui-monospace, monospace; white-space: nowrap; }
|
||
.archive-log-content-cell { min-width: 280px; color: #1f2937; }
|
||
.archive-log-time { min-width: 168px; color: #475569; white-space: nowrap; }
|
||
.archive-log-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 16px;
|
||
flex: 0 0 auto;
|
||
padding-top: 8px;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
.archive-log-pagination { display: inline-flex; align-items: center; gap: 7px; }
|
||
.archive-log-pagination button {
|
||
display: inline-flex;
|
||
width: 28px;
|
||
height: 28px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
border: 1px solid #d4d4d8;
|
||
border-radius: 3px;
|
||
background: #fff;
|
||
color: #475569;
|
||
cursor: pointer;
|
||
}
|
||
.archive-log-pagination button:disabled { cursor: not-allowed; opacity: 0.4; }
|
||
.archive-log-pagination button:hover:not(:disabled) { background: #f8fafc; border-color: #94a3b8; }
|
||
@media (max-width: 760px) {
|
||
.archive-log-overlay { padding: 12px; }
|
||
.archive-log-modal { width: 100%; max-height: calc(100vh - 24px); min-height: calc(100vh - 24px); padding: 16px; }
|
||
.archive-log-footer { justify-content: space-between; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
}
|