334 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}