feat(supply-chain): 产品全量档案负责人治理与聚水潭式表头改造
This commit is contained in:
parent
fb1c74ae67
commit
83f0fbee86
8
furniture-3d-system/package-lock.json
generated
8
furniture-3d-system/package-lock.json
generated
@ -43,7 +43,7 @@
|
||||
"next": "^16.1.6",
|
||||
"obj2gltf": "^3.2.0",
|
||||
"pg": "^8.17.2",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"pinyin-pro": "^3.29.5",
|
||||
"postcss": "^8.5.6",
|
||||
"print-js": "^1.6.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
@ -15537,9 +15537,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pinyin-pro": {
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.1.tgz",
|
||||
"integrity": "sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==",
|
||||
"version": "3.29.5",
|
||||
"resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.29.5.tgz",
|
||||
"integrity": "sha512-Fk5YoRmtdHqJWqAuzYVG/NzvzeCbaG+ZEwbY6Nbps3QAIrvQQEtcm49frcyCM8i2b5najnzPDtYqrHRYTTiuwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
|
||||
@ -157,7 +157,7 @@
|
||||
"next": "^16.1.6",
|
||||
"obj2gltf": "^3.2.0",
|
||||
"pg": "^8.17.2",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"pinyin-pro": "^3.29.5",
|
||||
"postcss": "^8.5.6",
|
||||
"print-js": "^1.6.0",
|
||||
"puppeteer": "^24.40.0",
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import type {
|
||||
ProductArchiveOwnerImportProgress,
|
||||
ProductArchiveOwnerImportRow,
|
||||
ProductArchiveOwnerImportStreamEvent,
|
||||
} from '@/lib/supply-chain-product-archive-owner-import'
|
||||
import { ErrorCode, isAppError } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError } from '@/server/core/response'
|
||||
import { supplyChainProductArchiveOwnerImportService } from '@/server/services/supply-chain-product-archive-owner-import.service'
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_ROWS = 5_000
|
||||
|
||||
function cellText(value: unknown) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function parseRows(sheet: XLSX.WorkSheet) {
|
||||
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false,
|
||||
})
|
||||
const headerRow = matrix[0]?.map(cellText) ?? []
|
||||
const headerIndex = new Map(headerRow.map((header, index) => [header, index]))
|
||||
const barcodeIndex = headerIndex.get('条码') ?? headerIndex.get('barcode')
|
||||
if (barcodeIndex === undefined) return { error: '导入文件缺少“条码”列', rows: [] as ProductArchiveOwnerImportRow[] }
|
||||
const opsHeadIndex = headerIndex.get('运营负责人') ?? headerIndex.get('opsHead')
|
||||
const designerIndex = headerIndex.get('设计师') ?? headerIndex.get('designer')
|
||||
|
||||
const rows = matrix.slice(1).flatMap((values, index) => {
|
||||
if (!values.some((value) => cellText(value))) return []
|
||||
return [{
|
||||
rowNumber: index + 2,
|
||||
barcode: cellText(values[barcodeIndex]),
|
||||
opsHead: opsHeadIndex === undefined ? '' : cellText(values[opsHeadIndex]),
|
||||
designer: designerIndex === undefined ? '' : cellText(values[designerIndex]),
|
||||
} satisfies ProductArchiveOwnerImportRow]
|
||||
})
|
||||
return { rows }
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const form = await request.formData()
|
||||
const file = form.get('file')
|
||||
if (!(file instanceof File)) {
|
||||
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, 'Excel 文件不能超过10MB', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer' })
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
if (!sheet) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
|
||||
}
|
||||
const parsed = parseRows(sheet)
|
||||
if (parsed.error) {
|
||||
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, parsed.error, 400, ctx.requestId)
|
||||
}
|
||||
if (parsed.rows.length === 0) {
|
||||
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
|
||||
}
|
||||
if (parsed.rows.length > MAX_ROWS) {
|
||||
return jsonError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const initial: ProductArchiveOwnerImportProgress = {
|
||||
processed: 0,
|
||||
total: parsed.rows.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false
|
||||
let progress = initial
|
||||
const send = (event: ProductArchiveOwnerImportStreamEvent) => {
|
||||
if (closed) return
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
|
||||
} catch {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
|
||||
send({ type: 'progress', progress })
|
||||
void supplyChainProductArchiveOwnerImportService.importRows(parsed.rows, ctx.user, (next) => {
|
||||
progress = next
|
||||
send({ type: 'progress', progress })
|
||||
}).then((result) => {
|
||||
progress = result
|
||||
send({ type: 'complete', progress })
|
||||
close()
|
||||
}).catch((error: unknown) => {
|
||||
console.error(`[${ctx.requestId}] Product archive owner import failed:`, error)
|
||||
send({
|
||||
type: 'error',
|
||||
progress,
|
||||
message: isAppError(error) ? error.message : '导入过程中发生错误',
|
||||
})
|
||||
close()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { PRODUCT_ARCHIVE_OWNER_IMPORT_COLUMNS } from '@/lib/supply-chain-product-archive-owner-import'
|
||||
import { handleRequest } from '@/server/core/response'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async () => {
|
||||
const headers = [...PRODUCT_ARCHIVE_OWNER_IMPORT_COLUMNS]
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers])
|
||||
sheet['!cols'] = headers.map((header) => ({ wch: Math.max(18, header.length * 2 + 8) }))
|
||||
const workbook = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, '负责人导入')
|
||||
const content = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
|
||||
return new NextResponse(new Uint8Array(content), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent('运营负责人导入模板.xlsx')}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
|
||||
allowedBusinessRoles: ['MANAGEMENT'],
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export type ArchiveToolbarMenuItem = {
|
||||
key: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
/** 提供时渲染为链接(如模板下载)。 */
|
||||
href?: string
|
||||
disabled?: boolean
|
||||
/** 右侧灰色说明。 */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏操作下拉菜单(导入 / 导出 / 更多 共用)。
|
||||
* 收纳零散按钮,避免操作行随着能力增加无限变宽。
|
||||
*/
|
||||
export function ProductArchiveActionMenu({
|
||||
label,
|
||||
icon,
|
||||
items,
|
||||
title,
|
||||
}: {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
items: ArchiveToolbarMenuItem[]
|
||||
title?: string
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
const runItem = (item: ArchiveToolbarMenuItem) => {
|
||||
setOpen(false)
|
||||
item.onClick?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="archive-action-menu" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="archive-action-menu-trigger"
|
||||
title={title ?? label}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
<ChevronDown size={12} strokeWidth={1.8} className={open ? 'is-open' : ''} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="archive-action-menu-panel" role="menu">
|
||||
{items.map((item) =>
|
||||
item.href && !item.disabled ? (
|
||||
<a key={item.key} className="archive-action-menu-item" role="menuitem" href={item.href} download onClick={() => setOpen(false)}>
|
||||
{item.icon}
|
||||
<span className="archive-action-menu-item-label">{item.label}</span>
|
||||
{item.hint && <span className="archive-action-menu-item-hint">{item.hint}</span>}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="archive-action-menu-item"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => runItem(item)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="archive-action-menu-item-label">{item.label}</span>
|
||||
{item.hint && <span className="archive-action-menu-item-hint">{item.hint}</span>}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<style jsx>{`
|
||||
.archive-action-menu { position: relative; display: inline-flex; }
|
||||
.archive-action-menu-trigger {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #fff; border: 1px solid #e4e4e7; border-radius: 6px;
|
||||
font-size: 13px; color: #3f3f46; cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.archive-action-menu-trigger:hover { background: #f4f4f5; border-color: #a1a1aa; }
|
||||
.archive-action-menu-trigger :global(.is-open) { transform: rotate(180deg); }
|
||||
.archive-action-menu-panel {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
|
||||
min-width: 168px; padding: 5px;
|
||||
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
text-align: left;
|
||||
}
|
||||
.archive-action-menu-item {
|
||||
display: flex; width: 100%; align-items: center; gap: 7px;
|
||||
padding: 7px 9px; border: none; border-radius: 5px;
|
||||
background: transparent; color: #3f3f46; font-size: 13px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.archive-action-menu-item:hover { background: #f4f4f5; color: #18181b; }
|
||||
.archive-action-menu-item:disabled { color: #a1a1aa; cursor: not-allowed; }
|
||||
.archive-action-menu-item:disabled:hover { background: transparent; }
|
||||
.archive-action-menu-item-label { flex: 1; }
|
||||
.archive-action-menu-item-hint { color: #a1a1aa; font-size: 11px; white-space: nowrap; }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronUp, Search, X } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import {
|
||||
ARCHIVE_PRECISE_FIELDS,
|
||||
type ArchiveToolbarField,
|
||||
} from './productArchiveToolbarFields'
|
||||
import type { ArchiveSavedQuery } from './useProductArchiveToolbarPreferences'
|
||||
import { ProductArchiveOptionFieldFilter } from './ProductArchiveOptionFieldFilter'
|
||||
import { ProductArchiveQuickFieldFilter } from './ProductArchiveQuickFieldFilter'
|
||||
import { ProductArchiveFilterSettings } from './ProductArchiveFilterSettings'
|
||||
import { ProductArchiveSavedQueries } from './ProductArchiveSavedQueries'
|
||||
|
||||
const KEYWORD_COMMIT_DELAY_MS = 600
|
||||
|
||||
/**
|
||||
* 全量档案顶部筛选行(表头第一行,可收起):
|
||||
* 模糊搜索框(SPU/SKU/条码/商品名称/供应商)+ 常显点选字段框(点击弹可搜索勾选小弹窗,
|
||||
* 设置可配)+「请选择+请输入」+ 另存快捷查询;最右端边角是「设置 / 收起」。
|
||||
*/
|
||||
export function ProductArchiveFilterBar({
|
||||
filters,
|
||||
onChange,
|
||||
preciseFields,
|
||||
savedQueries,
|
||||
onSaveQuery,
|
||||
onDeleteQuery,
|
||||
onPreciseFieldsChange,
|
||||
onCollapse,
|
||||
}: {
|
||||
filters: ProductArchiveFilters
|
||||
onChange: (nextFilters: ProductArchiveFilters) => void
|
||||
preciseFields: string[]
|
||||
savedQueries: ArchiveSavedQuery[]
|
||||
onSaveQuery: (name: string) => void
|
||||
onDeleteQuery: (id: string) => void
|
||||
onPreciseFieldsChange: (next: string[]) => void
|
||||
onCollapse: () => void
|
||||
}) {
|
||||
const [keywordDraft, setKeywordDraft] = useState(filters.keyword ?? '')
|
||||
// 自己刚提交过的 filters 对象:同步输入框时跳过,避免把用户正在输入的文本重写掉。
|
||||
const lastWrittenRef = useRef<ProductArchiveFilters | null>(null)
|
||||
|
||||
// 列头筛选 / 清除 / 快捷查询 / URL 带入等外部变化 → 同步进搜索框。
|
||||
useEffect(() => {
|
||||
if (filters === lastWrittenRef.current) return
|
||||
lastWrittenRef.current = null
|
||||
setKeywordDraft(filters.keyword ?? '')
|
||||
}, [filters])
|
||||
|
||||
const commitNow = () => {
|
||||
const keyword = keywordDraft.trim()
|
||||
if ((filters.keyword ?? '') === keyword) return
|
||||
const next = { ...filters }
|
||||
if (keyword) next.keyword = keyword
|
||||
else delete next.keyword
|
||||
lastWrittenRef.current = next
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
// 输入停顿后自动提交,避免每个键击都触发一次列表请求。
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(commitNow, KEYWORD_COMMIT_DELAY_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
// commitNow 只依赖 keywordDraft/filters/onChange,这里故意不展开。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keywordDraft])
|
||||
|
||||
const visiblePreciseFields = preciseFields
|
||||
.map((key) => ARCHIVE_PRECISE_FIELDS.find((item) => item.key === key))
|
||||
.filter((item): item is ArchiveToolbarField => Boolean(item))
|
||||
|
||||
const activeFilterCount = Object.entries(filters).filter(([key, value]) =>
|
||||
!['salesGranularity', 'salesPeriod'].includes(key)
|
||||
&& (Array.isArray(value) ? value.length > 0 : Boolean(value)),
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="filterbar">
|
||||
<div className="filterbar-search">
|
||||
<Search size={14} strokeWidth={1.8} />
|
||||
<input
|
||||
value={keywordDraft}
|
||||
placeholder="搜索 SPU / SKU / 条码 / 商品名称 / 供应商"
|
||||
aria-label="模糊搜索"
|
||||
onChange={(event) => setKeywordDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitNow()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
lastWrittenRef.current = null
|
||||
setKeywordDraft(filters.keyword ?? '')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{keywordDraft && (
|
||||
<button
|
||||
type="button"
|
||||
className="filterbar-clear"
|
||||
aria-label="清空搜索"
|
||||
onClick={() => setKeywordDraft('')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visiblePreciseFields.map((item) => (
|
||||
<ProductArchiveOptionFieldFilter
|
||||
key={item.key}
|
||||
field={item}
|
||||
filters={filters}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ProductArchiveQuickFieldFilter filters={filters} onChange={onChange} />
|
||||
|
||||
<ProductArchiveSavedQueries
|
||||
queries={savedQueries}
|
||||
currentFilters={filters}
|
||||
canSave={activeFilterCount > 0}
|
||||
onApply={onChange}
|
||||
onSave={onSaveQuery}
|
||||
onDelete={onDeleteQuery}
|
||||
/>
|
||||
|
||||
<div className="filterbar-tail">
|
||||
<ProductArchiveFilterSettings
|
||||
selected={preciseFields}
|
||||
onChange={onPreciseFieldsChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="filterbar-collapse"
|
||||
title="收起筛选行"
|
||||
aria-label="收起筛选行"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
收起
|
||||
<ChevronUp size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.filterbar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fff; border: 1px solid #f4f4f5; border-radius: 8px;
|
||||
flex-shrink: 0; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.filterbar-search {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
width: 280px; height: 30px; padding: 0 8px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
background: #fafafa; color: #71717a;
|
||||
}
|
||||
.filterbar-search:focus-within { border-color: #52525b; background: #fff; }
|
||||
.filterbar-search input {
|
||||
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
|
||||
background: transparent; color: #27272a; font-size: 12px;
|
||||
}
|
||||
.filterbar-clear {
|
||||
display: inline-flex; padding: 0; border: none; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.filterbar-clear:hover { color: #52525b; }
|
||||
.filterbar-tail {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.filterbar-collapse {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 28px; padding: 0 8px;
|
||||
border: none; border-radius: 4px;
|
||||
background: transparent; color: #71717a;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.filterbar-collapse:hover { background: #f4f4f5; color: #27272a; }
|
||||
@media (max-width: 900px) {
|
||||
.filterbar-tail { margin-left: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -132,11 +132,17 @@ export function ProductArchiveFilterOptionList({
|
||||
)}
|
||||
</div>
|
||||
<div className="archive-header-filter-selected-summary">
|
||||
<span>
|
||||
<span className="archive-header-filter-summary-text" title={
|
||||
selectedValues.length > 0
|
||||
? `已选 ${selectedValues.length} 项`
|
||||
: optionsTotal !== null
|
||||
? `共 ${optionsTotal} 项${hasMoreOptions ? `,已加载 ${visibleOptions.length} 项,滚动加载更多` : ''}`
|
||||
: `共 ${visibleOptions.length} 项`
|
||||
}>
|
||||
{selectedValues.length > 0
|
||||
? `已选 ${selectedValues.length} 项`
|
||||
: optionsTotal !== null
|
||||
? `共 ${optionsTotal} 项${hasMoreOptions ? `(已加载 ${visibleOptions.length},滚动加载更多)` : ''}`
|
||||
? `共 ${optionsTotal} 项${hasMoreOptions ? ` · 已加载 ${visibleOptions.length}` : ''}`
|
||||
: `共 ${visibleOptions.length} 项`}
|
||||
</span>
|
||||
{visibleOptions.length > 0 && hasMoreOptions && optionsTotal !== null && (
|
||||
@ -146,7 +152,7 @@ export function ProductArchiveFilterOptionList({
|
||||
disabled={loadingAllOptions}
|
||||
onClick={() => void selectAllMatching()}
|
||||
>
|
||||
{loadingAllOptions ? '正在加载全部候选…' : `全选 ${optionsTotal} 项`}
|
||||
{loadingAllOptions ? '加载全部…' : `全选 ${optionsTotal} 项`}
|
||||
</button>
|
||||
)}
|
||||
{visibleOptions.length > 0 && !(hasMoreOptions && optionsTotal !== null) && (
|
||||
@ -203,12 +209,15 @@ export function ProductArchiveFilterOptionList({
|
||||
border: none; background: transparent; color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.archive-header-filter-selected-summary {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 8px 3px 5px; color: #52525b; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.archive-header-filter-summary-text {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.archive-header-filter-select-all {
|
||||
padding: 0; border: none; background: transparent; color: #2563eb;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
flex: 0 0 auto; padding: 0; border: none; background: transparent; color: #2563eb;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.archive-header-filter-select-all:hover { text-decoration: underline; }
|
||||
.archive-header-filter-options {
|
||||
|
||||
@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, GripVertical, Settings2, X } from 'lucide-react'
|
||||
import {
|
||||
ARCHIVE_PRECISE_FIELDS,
|
||||
DEFAULT_ARCHIVE_PRECISE_FIELDS,
|
||||
} from './productArchiveToolbarFields'
|
||||
|
||||
/**
|
||||
* 筛选区「设置」面板:配置顶部常显哪些字段框、以及显示顺序。
|
||||
* 已勾选行可拖拽排序(交互与列设置一致:手柄+勾选框+字段名),顺序即时反映到顶部;
|
||||
* 恢复默认 = 默认十项与默认顺序。
|
||||
*/
|
||||
export function ProductArchiveFilterSettings({
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
selected: string[]
|
||||
onChange: (next: string[]) => void
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const dragKeyRef = useRef<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
// 清单顺序与顶部同步:已勾选字段按用户排的顺序在前(=顶部显示顺序),未勾选的按池顺序跟在后面。
|
||||
const visibleFields = useMemo(() => {
|
||||
const selectedSet = new Set(selected)
|
||||
const ordered = [
|
||||
...selected
|
||||
.map((key) => ARCHIVE_PRECISE_FIELDS.find((item) => item.key === key))
|
||||
.filter((item): item is (typeof ARCHIVE_PRECISE_FIELDS)[number] => Boolean(item)),
|
||||
...ARCHIVE_PRECISE_FIELDS.filter((item) => !selectedSet.has(item.key)),
|
||||
]
|
||||
const keyword = searchText.trim().toLowerCase()
|
||||
if (!keyword) return ordered
|
||||
return ordered.filter(
|
||||
(item) => item.label.toLowerCase().includes(keyword) || item.key.toLowerCase().includes(keyword),
|
||||
)
|
||||
}, [searchText, selected])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
if (selected.includes(key)) onChange(selected.filter((item) => item !== key))
|
||||
else onChange([...selected, key])
|
||||
}
|
||||
|
||||
/** 拖拽重排:把 fromKey 移到 toKey 的位置。只对已勾选(会显示在顶部)的字段生效。 */
|
||||
const dropOn = (toKey: string) => {
|
||||
const fromKey = dragKeyRef.current
|
||||
dragKeyRef.current = null
|
||||
if (!fromKey || fromKey === toKey) return
|
||||
const fromIndex = selected.indexOf(fromKey)
|
||||
const toIndex = selected.indexOf(toKey)
|
||||
if (fromIndex < 0 || toIndex < 0) return
|
||||
const next = [...selected]
|
||||
next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, fromKey)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
/** 上移/下移兜底:拖拽不可用的环境也能调整顺序。 */
|
||||
const moveBy = (key: string, offset: -1 | 1) => {
|
||||
const index = selected.indexOf(key)
|
||||
const target = index + offset
|
||||
if (index < 0 || target < 0 || target >= selected.length) return
|
||||
const next = [...selected]
|
||||
next.splice(target, 0, next.splice(index, 1)[0])
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="filter-settings" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="filter-settings-trigger"
|
||||
title="设置顶部常显的字段框与顺序"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setSearchText('')
|
||||
setOpen((current) => !current)
|
||||
}}
|
||||
>
|
||||
<Settings2 size={14} strokeWidth={1.8} />
|
||||
设置
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="filter-settings-panel" role="dialog" aria-label="设置常显字段框与顺序">
|
||||
<div className="filter-settings-hint">勾选常显在筛选行的字段框;拖动已勾选行调整显示顺序</div>
|
||||
<div className="filter-settings-search">
|
||||
<input
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder="搜索字段名"
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
/>
|
||||
{searchText && (
|
||||
<button type="button" aria-label="清空字段搜索" onClick={() => setSearchText('')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-settings-list">
|
||||
{visibleFields.map((item) => {
|
||||
const isSelected = selected.includes(item.key)
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`filter-settings-row ${isSelected ? 'is-selected' : ''}`}
|
||||
draggable={isSelected}
|
||||
onDragStart={(event) => {
|
||||
dragKeyRef.current = item.key
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', item.key)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragKeyRef.current = null
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (isSelected) event.preventDefault()
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
dropOn(item.key)
|
||||
}}
|
||||
>
|
||||
<span className={`filter-settings-drag ${isSelected ? '' : 'is-disabled'}`}>
|
||||
<GripVertical size={13} />
|
||||
</span>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggle(item.key)}
|
||||
/>
|
||||
<span title={item.label}>{item.label}</span>
|
||||
</label>
|
||||
{isSelected && (
|
||||
<span className="filter-settings-move">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`上移${item.label}`}
|
||||
title="上移"
|
||||
disabled={selected.indexOf(item.key) === 0}
|
||||
onClick={() => moveBy(item.key, -1)}
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`下移${item.label}`}
|
||||
title="下移"
|
||||
disabled={selected.indexOf(item.key) === selected.length - 1}
|
||||
onClick={() => moveBy(item.key, 1)}
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{visibleFields.length === 0 && (
|
||||
<div className="filter-settings-empty">没有匹配的字段</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-settings-footer">
|
||||
<span className="filter-settings-count">已选 {selected.length} 项</span>
|
||||
<div className="filter-settings-footer-buttons">
|
||||
<button type="button" onClick={() => onChange([...DEFAULT_ARCHIVE_PRECISE_FIELDS])}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button type="button" className="primary" onClick={() => setOpen(false)}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.filter-settings { position: relative; display: inline-flex; }
|
||||
.filter-settings-trigger {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 28px; padding: 0 8px;
|
||||
border: none; border-radius: 4px;
|
||||
background: transparent; color: #71717a;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.filter-settings-trigger:hover { background: #f4f4f5; color: #27272a; }
|
||||
.filter-settings-panel {
|
||||
position: absolute; top: calc(100% + 7px); right: 0; z-index: 40;
|
||||
display: flex; flex-direction: column;
|
||||
width: 280px; max-height: 440px; padding: 10px;
|
||||
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.filter-settings-hint { color: #71717a; font-size: 12px; padding: 0 1px 8px; }
|
||||
.filter-settings-search {
|
||||
display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 8px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px; color: #a1a1aa;
|
||||
}
|
||||
.filter-settings-search:focus-within { border-color: #52525b; }
|
||||
.filter-settings-search input {
|
||||
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
|
||||
color: #27272a; font-size: 13px;
|
||||
}
|
||||
.filter-settings-search button {
|
||||
display: inline-flex; padding: 0; border: none; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.filter-settings-list {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
margin-top: 8px; overflow-y: auto; min-height: 100px;
|
||||
}
|
||||
.filter-settings-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
min-height: 30px; padding: 0 2px 0 0; border-radius: 4px;
|
||||
}
|
||||
.filter-settings-row.is-selected { cursor: grab; }
|
||||
.filter-settings-row.is-selected:active { cursor: grabbing; }
|
||||
.filter-settings-row:hover, .filter-settings-row.is-selected { background: #f4f4f5; }
|
||||
.filter-settings-drag {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 20px; flex: 0 0 auto; color: #a1a1aa;
|
||||
}
|
||||
.filter-settings-drag.is-disabled { opacity: 0.25; }
|
||||
.filter-settings-row label {
|
||||
display: flex; flex: 1; align-items: center; gap: 7px;
|
||||
min-height: 30px; padding: 2px 5px 2px 0;
|
||||
color: #3f3f46; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.filter-settings-row.is-selected label { color: #18181b; font-weight: 550; }
|
||||
.filter-settings-row label span {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.filter-settings-move { display: inline-flex; gap: 1px; flex: 0 0 auto; }
|
||||
.filter-settings-move button {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; padding: 0;
|
||||
border: none; border-radius: 3px; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.filter-settings-move button:hover:not(:disabled) { background: #e4e4e7; color: #27272a; }
|
||||
.filter-settings-move button:disabled { opacity: 0.3; cursor: default; }
|
||||
.filter-settings-row input[type='checkbox'] {
|
||||
width: 14px; height: 14px; margin: 0; flex: 0 0 auto; accent-color: #27272a;
|
||||
}
|
||||
.filter-settings-empty { padding: 20px 0; color: #a1a1aa; font-size: 12px; text-align: center; }
|
||||
.filter-settings-footer {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
margin-top: 10px; padding-top: 10px; border-top: 1px solid #e4e4e7;
|
||||
}
|
||||
.filter-settings-count { color: #71717a; font-size: 12px; }
|
||||
.filter-settings-footer-buttons { display: inline-flex; gap: 7px; }
|
||||
.filter-settings-footer-buttons button {
|
||||
height: 28px; padding: 0 12px; border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
background: #fff; color: #52525b; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.filter-settings-footer-buttons button:hover { background: #f4f4f5; }
|
||||
.filter-settings-footer-buttons button.primary {
|
||||
border-color: #27272a; background: #27272a; color: #fff;
|
||||
}
|
||||
.filter-settings-footer-buttons button.primary:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import {
|
||||
ARCHIVE_QUICK_FIELD_OPTIONS,
|
||||
splitArchiveFilterValues,
|
||||
} from './productArchiveToolbarFields'
|
||||
|
||||
/**
|
||||
* 「多项筛选」居中大弹窗:字段池全部字段平铺(字段名 + 输入框),
|
||||
* 填了值的字段参与筛选(字段间「且」,同字段逗号多值「或」),
|
||||
* 顶部可搜字段名、清空全部,底部取消/确定。
|
||||
*/
|
||||
export function ProductArchiveMultiFieldDialog({
|
||||
filters,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
filters: ProductArchiveFilters
|
||||
onConfirm: (nextFilters: ProductArchiveFilters) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [texts, setTexts] = useState<Record<string, string>>(() => {
|
||||
const record = filters as Record<string, unknown>
|
||||
const initial: Record<string, string> = {}
|
||||
for (const item of ARCHIVE_QUICK_FIELD_OPTIONS) {
|
||||
const values = record[item.key]
|
||||
if (Array.isArray(values) && values.length > 0) {
|
||||
initial[item.key] = values.map((value) => String(value)).join(',')
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onCancel()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
const visibleFields = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase()
|
||||
if (!keyword) return ARCHIVE_QUICK_FIELD_OPTIONS
|
||||
return ARCHIVE_QUICK_FIELD_OPTIONS.filter(
|
||||
(item) => item.label.toLowerCase().includes(keyword) || item.key.toLowerCase().includes(keyword),
|
||||
)
|
||||
}, [searchText])
|
||||
|
||||
const filledCount = useMemo(
|
||||
() => ARCHIVE_QUICK_FIELD_OPTIONS.filter((item) => splitArchiveFilterValues(texts[item.key] ?? '').length > 0).length,
|
||||
[texts],
|
||||
)
|
||||
|
||||
const setText = (key: string, text: string) => {
|
||||
setTexts((current) => ({ ...current, [key]: text }))
|
||||
}
|
||||
|
||||
const clearAll = () => setTexts({})
|
||||
|
||||
const confirm = () => {
|
||||
const next = { ...filters } as ProductArchiveFilters
|
||||
const record = next as Record<string, unknown>
|
||||
for (const item of ARCHIVE_QUICK_FIELD_OPTIONS) {
|
||||
const values = splitArchiveFilterValues(texts[item.key] ?? '')
|
||||
if (values.length > 0) record[item.key] = values
|
||||
else delete record[item.key]
|
||||
}
|
||||
onConfirm(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="multi-dialog-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="多项筛选"
|
||||
onPointerDown={(event) => {
|
||||
if (event.target === event.currentTarget) onCancel()
|
||||
}}
|
||||
>
|
||||
<div className="multi-dialog">
|
||||
<div className="multi-dialog-head">
|
||||
<span className="multi-dialog-title">多项筛选</span>
|
||||
<button type="button" className="multi-dialog-close" aria-label="关闭" onClick={onCancel}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="multi-dialog-toolbar">
|
||||
<div className="multi-dialog-search">
|
||||
<Search size={14} strokeWidth={1.8} />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder="搜索字段名,如:供应商 / 仓库 / 标签"
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
{searchText && (
|
||||
<button type="button" aria-label="清空字段搜索" onClick={() => setSearchText('')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="multi-dialog-clear"
|
||||
onClick={clearAll}
|
||||
disabled={filledCount === 0}
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="multi-dialog-body">
|
||||
<div className="multi-dialog-grid">
|
||||
{visibleFields.map((item) => {
|
||||
const text = texts[item.key] ?? ''
|
||||
return (
|
||||
<label key={item.key} className={`multi-dialog-field ${text ? 'has-value' : ''}`}>
|
||||
<span className="multi-dialog-field-label" title={item.label}>{item.label}</span>
|
||||
<input
|
||||
value={text}
|
||||
placeholder="多个值用逗号分隔"
|
||||
aria-label={`${item.label}筛选值`}
|
||||
onChange={(event) => setText(item.key, event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
confirm()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{visibleFields.length === 0 && (
|
||||
<div className="multi-dialog-state">没有匹配的字段</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="multi-dialog-footer">
|
||||
<span className="multi-dialog-count">已填 {filledCount} 项 · 未填写的字段不参与筛选</span>
|
||||
<div className="multi-dialog-footer-buttons">
|
||||
<button type="button" className="cancel" onClick={onCancel}>取消</button>
|
||||
<button type="button" className="confirm" onClick={confirm}>
|
||||
确定{filledCount > 0 ? `(${filledCount})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.multi-dialog-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(24, 24, 27, 0.45);
|
||||
padding: 24px;
|
||||
}
|
||||
.multi-dialog {
|
||||
display: flex; flex-direction: column;
|
||||
width: 880px; max-width: 100%; height: 560px; max-height: 100%;
|
||||
background: #fff; border-radius: 8px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
.multi-dialog-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid #f4f4f5; flex: 0 0 auto;
|
||||
}
|
||||
.multi-dialog-title { font-size: 14px; font-weight: 700; color: #18181b; }
|
||||
.multi-dialog-close {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; padding: 0; border: none; border-radius: 5px;
|
||||
background: transparent; color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.multi-dialog-close:hover { background: #f4f4f5; color: #52525b; }
|
||||
.multi-dialog-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px 0; flex: 0 0 auto;
|
||||
}
|
||||
.multi-dialog-search {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1; max-width: 360px;
|
||||
height: 32px; padding: 0 9px;
|
||||
border: 1px solid #d4d4d8; border-radius: 5px; color: #a1a1aa;
|
||||
}
|
||||
.multi-dialog-search:focus-within { border-color: #52525b; }
|
||||
.multi-dialog-search input {
|
||||
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
|
||||
color: #27272a; font-size: 13px;
|
||||
}
|
||||
.multi-dialog-search button {
|
||||
display: inline-flex; padding: 0; border: none; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.multi-dialog-clear {
|
||||
margin-left: auto; height: 30px; padding: 0 10px;
|
||||
border: 1px solid #d4d4d8; border-radius: 5px;
|
||||
background: #fff; color: #3f3f46; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.multi-dialog-clear:hover:not(:disabled) { background: #f4f4f5; }
|
||||
.multi-dialog-clear:disabled { color: #a1a1aa; cursor: not-allowed; }
|
||||
.multi-dialog-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 16px; }
|
||||
.multi-dialog-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px;
|
||||
}
|
||||
.multi-dialog-field { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.multi-dialog-field-label {
|
||||
flex: 0 0 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 12px; font-weight: 600; color: #3f3f46; text-align: right;
|
||||
}
|
||||
.multi-dialog-field input {
|
||||
min-width: 0; flex: 1; height: 30px; padding: 0 8px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
background: #fff; color: #27272a; font-size: 12px; outline: none;
|
||||
}
|
||||
.multi-dialog-field input:focus { border-color: #52525b; }
|
||||
.multi-dialog-field.has-value input { border-color: #2563eb; }
|
||||
.multi-dialog-state {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 60px 20px; color: #a1a1aa; font-size: 13px;
|
||||
}
|
||||
.multi-dialog-footer {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 12px 16px; border-top: 1px solid #f4f4f5; flex: 0 0 auto;
|
||||
}
|
||||
.multi-dialog-count { color: #71717a; font-size: 12px; }
|
||||
.multi-dialog-footer-buttons { display: inline-flex; gap: 8px; }
|
||||
.multi-dialog-footer-buttons button {
|
||||
height: 32px; padding: 0 18px; border-radius: 5px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.multi-dialog-footer-buttons .cancel { border: 1px solid #d4d4d8; background: #fff; color: #52525b; }
|
||||
.multi-dialog-footer-buttons .cancel:hover { background: #f4f4f5; }
|
||||
.multi-dialog-footer-buttons .confirm { border: 1px solid #27272a; background: #27272a; color: #fff; }
|
||||
.multi-dialog-footer-buttons .confirm:hover { background: #3f3f46; }
|
||||
@media (max-width: 960px) {
|
||||
.multi-dialog { width: 100%; height: 90vh; }
|
||||
.multi-dialog-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import type { ArchiveToolbarField } from './productArchiveToolbarFields'
|
||||
import { ProductArchiveSelectionDialog } from './ProductArchiveSelectionDialog'
|
||||
|
||||
/**
|
||||
* 顶部点选字段框:框内显示已选值或占位字段名,点击弹出居中的
|
||||
* 「选择面板」大弹窗(搜索 + 全选/反选/清空 + 拼音分组 + A-Z 索引 + 取消/确定)。
|
||||
*/
|
||||
export function ProductArchiveOptionFieldFilter({
|
||||
field,
|
||||
filters,
|
||||
onChange,
|
||||
}: {
|
||||
field: ArchiveToolbarField
|
||||
filters: ProductArchiveFilters
|
||||
onChange: (nextFilters: ProductArchiveFilters) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const record = filters as Record<string, unknown>
|
||||
const activeValues = Array.isArray(record[field.key])
|
||||
? (record[field.key] as unknown[]).map(String)
|
||||
: []
|
||||
|
||||
const writeValues = (values: string[]) => {
|
||||
const next = { ...filters } as ProductArchiveFilters
|
||||
const target = next as Record<string, unknown>
|
||||
if (values.length > 0) target[field.key] = values
|
||||
else delete target[field.key]
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="option-field">
|
||||
<button
|
||||
type="button"
|
||||
className={`option-field-trigger ${activeValues.length > 0 ? 'has-value' : ''}`}
|
||||
title={`${field.label}:点击勾选筛选`}
|
||||
aria-expanded={open}
|
||||
aria-label={`${field.label}筛选`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="option-field-value">
|
||||
{activeValues.length > 0
|
||||
? activeValues.join(',')
|
||||
: <span className="option-field-placeholder">{field.label}</span>}
|
||||
</span>
|
||||
<ChevronDown size={12} strokeWidth={1.8} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ProductArchiveSelectionDialog
|
||||
field={field}
|
||||
filters={filters}
|
||||
onConfirm={(values) => {
|
||||
writeValues(values)
|
||||
setOpen(false)
|
||||
}}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.option-field { position: relative; display: inline-flex; }
|
||||
.option-field-trigger {
|
||||
display: inline-flex; align-items: center; justify-content: space-between; gap: 4px;
|
||||
height: 30px; width: 140px; padding: 0 8px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
background: #fff; color: #27272a; font-size: 12px; cursor: pointer; outline: none;
|
||||
}
|
||||
.option-field-trigger:hover { border-color: #a1a1aa; }
|
||||
.option-field-trigger.has-value { border-color: #2563eb; color: #1d4ed8; }
|
||||
.option-field-value {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
.option-field-placeholder { color: #a1a1aa; }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, CheckCircle2, LoaderCircle, X } from 'lucide-react'
|
||||
import type { ProductArchiveOwnerImportState } from './useProductArchiveOwnerImport'
|
||||
|
||||
export function ProductArchiveOwnerImportProgress({
|
||||
state,
|
||||
onDismiss,
|
||||
}: {
|
||||
state: ProductArchiveOwnerImportState | null
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
if (!state) return null
|
||||
const running = state.status === 'preparing' || state.status === 'running'
|
||||
const percent = state.total > 0 ? Math.round((state.processed / state.total) * 100) : 0
|
||||
const Icon = running ? LoaderCircle : state.status === 'complete' ? CheckCircle2 : AlertTriangle
|
||||
|
||||
return (
|
||||
<aside className="owner-import-progress" aria-live="polite">
|
||||
<header>
|
||||
<Icon size={17} className={running ? 'is-spinning' : ''} />
|
||||
<div>
|
||||
<strong>{running ? '正在导入负责人' : state.status === 'complete' ? '导入完成' : '导入失败'}</strong>
|
||||
<span title={state.fileName}>{state.fileName}</span>
|
||||
</div>
|
||||
{!running ? <button type="button" aria-label="关闭导入结果" onClick={onDismiss}><X size={15} /></button> : null}
|
||||
</header>
|
||||
<div className="owner-import-track"><span style={{ width: `${percent}%` }} /></div>
|
||||
<div className="owner-import-stats">
|
||||
<span>{state.processed}/{state.total || '-'}</span>
|
||||
<span>成功 {state.updated}</span>
|
||||
<span>跳过 {state.skipped}</span>
|
||||
<span className={state.failed > 0 ? 'has-error' : ''}>失败 {state.failed}</span>
|
||||
</div>
|
||||
{state.message ? <p className="owner-import-message">{state.message}</p> : null}
|
||||
{state.warnings.length > 0 ? (
|
||||
<div className="owner-import-warnings">
|
||||
{state.warnings.map((warning) => (
|
||||
<div key={`w-${warning.rowNumber}-${warning.barcode}-${warning.message}`}>
|
||||
<b>第 {warning.rowNumber} 行</b>
|
||||
<p>{warning.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{state.errors.length > 0 ? (
|
||||
<div className="owner-import-errors">
|
||||
{state.errors.map((error) => (
|
||||
<div key={`${error.rowNumber}-${error.barcode}-${error.message}`}>
|
||||
<b>第 {error.rowNumber} 行</b>
|
||||
<span>{error.barcode || '未填写条码'}</span>
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<style jsx>{`
|
||||
.owner-import-progress { position: fixed; right: 22px; bottom: 22px; z-index: 120; width: min(440px,calc(100vw - 32px)); max-height: min(520px,calc(100vh - 44px)); overflow: auto; border: 1px solid #d4d4d8; border-radius: 8px; padding: 15px; background: #fff; box-shadow: 0 18px 50px rgba(24,24,27,.18); color: #27272a; }
|
||||
header { display: flex; align-items: flex-start; gap: 9px; }
|
||||
header > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
|
||||
header strong { font-size: 13px; }
|
||||
header span { overflow: hidden; color: #71717a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
header button { display: grid; width: 26px; height: 26px; place-items: center; border: 0; background: transparent; color: #71717a; cursor: pointer; }
|
||||
:global(.is-spinning) { animation: owner-import-spin .8s linear infinite; }
|
||||
.owner-import-track { height: 5px; margin-top: 13px; overflow: hidden; border-radius: 5px; background: #e4e4e7; }
|
||||
.owner-import-track span { display: block; height: 100%; background: #3f3f46; transition: width .15s ease; }
|
||||
.owner-import-stats { display: flex; gap: 14px; margin-top: 9px; color: #52525b; font-size: 11px; }
|
||||
.owner-import-stats .has-error, .owner-import-message { color: #b91c1c; }
|
||||
.owner-import-message { margin: 10px 0 0; font-size: 12px; }
|
||||
.owner-import-warnings, .owner-import-errors { display: flex; max-height: 200px; flex-direction: column; gap: 7px; margin-top: 11px; overflow-y: auto; }
|
||||
.owner-import-warnings > div { display: grid; grid-template-columns: 72px 1fr; gap: 7px; font-size: 11px; color: #92400e; }
|
||||
.owner-import-errors > div { display: grid; grid-template-columns: 72px minmax(80px,130px) 1fr; gap: 7px; border-top: 1px solid #f0f0f1; padding-top: 7px; font-size: 11px; }
|
||||
.owner-import-errors b { color: #52525b; }
|
||||
.owner-import-warnings p { margin: 0; }
|
||||
.owner-import-errors span { overflow: hidden; color: #71717a; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.owner-import-errors p { margin: 0; color: #b91c1c; }
|
||||
@keyframes owner-import-spin { to { transform: rotate(360deg); } }
|
||||
`}</style>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { UserCheck } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
|
||||
type Props = {
|
||||
filters: ProductArchiveFilters
|
||||
onChange: (filters: ProductArchiveFilters) => void
|
||||
}
|
||||
|
||||
/** 工具栏上的「负责人在职状态」快捷筛选:一键把已离职/未匹配的档案区分出来。 */
|
||||
export function ProductArchiveOwnerStatusFilter({ filters, onChange }: Props) {
|
||||
const value = filters.ownerEmploymentStatus?.[0] ?? ''
|
||||
|
||||
const setStatus = (next: string) => {
|
||||
const updated: ProductArchiveFilters = { ...filters }
|
||||
if (next) updated.ownerEmploymentStatus = [next]
|
||||
else delete updated.ownerEmploymentStatus
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="owner-status-filter" aria-label="负责人在职状态筛选">
|
||||
<span className="owner-status-label"><UserCheck size={14} strokeWidth={1.8} />负责人在职状态</span>
|
||||
<select
|
||||
aria-label="负责人在职状态"
|
||||
value={value}
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="在职">在职</option>
|
||||
<option value="已离职">已离职</option>
|
||||
<option value="未匹配">未匹配</option>
|
||||
<option value="未设置">未设置</option>
|
||||
</select>
|
||||
<style jsx>{`
|
||||
.owner-status-filter { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.owner-status-label {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; font-weight: 600; color: #3f3f46; white-space: nowrap;
|
||||
}
|
||||
.owner-status-filter select {
|
||||
height: 30px; width: 96px; padding: 0 7px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
background: #fff; color: #27272a; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.owner-status-filter select:focus { outline: none; border-color: #52525b; }
|
||||
`}</style>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import {
|
||||
ARCHIVE_QUICK_FIELD_OPTIONS,
|
||||
findArchiveQuickField,
|
||||
splitArchiveFilterValues,
|
||||
} from './productArchiveToolbarFields'
|
||||
import { ProductArchiveMultiFieldDialog } from './ProductArchiveMultiFieldDialog'
|
||||
|
||||
const COMMIT_DELAY_MS = 600
|
||||
|
||||
function valuesEqual(filters: ProductArchiveFilters, key: string, text: string): boolean {
|
||||
const record = filters as Record<string, unknown>
|
||||
const values = Array.isArray(record[key]) ? (record[key] as unknown[]).map(String) : []
|
||||
const next = splitArchiveFilterValues(text)
|
||||
return values.length === next.length && values.every((value, index) => value === next[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* 「请选择 + 请输入」:自制下拉面板——上部字段列表(可搜索、可滚动),
|
||||
* 底部固定「多项筛选」拨动开关(不随列表滚动);选字段解锁右侧输入框,
|
||||
* 输入即筛(停顿自动/回车立即);开关打开居中大弹窗多条件叠加。
|
||||
*/
|
||||
export function ProductArchiveQuickFieldFilter({
|
||||
filters,
|
||||
onChange,
|
||||
}: {
|
||||
filters: ProductArchiveFilters
|
||||
onChange: (nextFilters: ProductArchiveFilters) => void
|
||||
}) {
|
||||
const [selectedKey, setSelectedKey] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [fieldSearch, setFieldSearch] = useState('')
|
||||
const [multiOpen, setMultiOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
// 自己刚提交过的 filters 对象:同步输入框时跳过,避免把用户正在输入的文本重写掉。
|
||||
const lastWrittenRef = useRef<ProductArchiveFilters | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
// 外部变化(列头/点选框/清除/快捷查询)→ 同步输入框(只跟随当前选中字段)。
|
||||
useEffect(() => {
|
||||
if (filters === lastWrittenRef.current) return
|
||||
lastWrittenRef.current = null
|
||||
if (!selectedKey) return
|
||||
const record = filters as Record<string, unknown>
|
||||
const values = Array.isArray(record[selectedKey]) ? (record[selectedKey] as unknown[]) : []
|
||||
setText(values.map(String).join(','))
|
||||
// selectedKey 变化时也要重取输入框,故入依赖。
|
||||
}, [filters, selectedKey])
|
||||
|
||||
const commit = () => {
|
||||
if (!selectedKey || valuesEqual(filters, selectedKey, text)) return
|
||||
const next = { ...filters } as ProductArchiveFilters
|
||||
const record = next as Record<string, unknown>
|
||||
const values = splitArchiveFilterValues(text)
|
||||
if (values.length > 0) record[selectedKey] = values
|
||||
else delete record[selectedKey]
|
||||
lastWrittenRef.current = next
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
// 输入停顿后自动提交,避免每个键击都触发一次列表请求。
|
||||
useEffect(() => {
|
||||
if (!selectedKey) return
|
||||
const timer = window.setTimeout(commit, COMMIT_DELAY_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
// commit 只依赖 text/filters/onChange,这里故意不展开。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text])
|
||||
|
||||
const chooseField = (key: string) => {
|
||||
setSelectedKey(key)
|
||||
const record = filters as Record<string, unknown>
|
||||
const values = Array.isArray(record[key]) ? (record[key] as unknown[]) : []
|
||||
setText(values.map(String).join(','))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const visibleFields = useMemo(() => {
|
||||
const keyword = fieldSearch.trim().toLowerCase()
|
||||
if (!keyword) return ARCHIVE_QUICK_FIELD_OPTIONS
|
||||
return ARCHIVE_QUICK_FIELD_OPTIONS.filter(
|
||||
(item) => item.label.toLowerCase().includes(keyword) || item.key.toLowerCase().includes(keyword),
|
||||
)
|
||||
}, [fieldSearch])
|
||||
|
||||
const activeQuickCount = ARCHIVE_QUICK_FIELD_OPTIONS.filter((item) => {
|
||||
const values = (filters as Record<string, unknown>)[item.key]
|
||||
return Array.isArray(values) && values.length > 0
|
||||
}).length
|
||||
|
||||
return (
|
||||
<span className="quick-field-filter" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-field-select ${open ? 'is-open' : ''} ${activeQuickCount > 0 && !selectedKey ? 'has-multi-active' : ''}`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="选择筛选字段"
|
||||
onClick={() => {
|
||||
setFieldSearch('')
|
||||
setOpen((current) => !current)
|
||||
}}
|
||||
>
|
||||
<span className="quick-field-select-value">
|
||||
{selectedKey ? findArchiveQuickField(selectedKey)?.label ?? '' : '请选择'}
|
||||
</span>
|
||||
<ChevronDown size={12} strokeWidth={1.8} className={open ? 'is-open' : ''} />
|
||||
</button>
|
||||
<input
|
||||
aria-label="输入筛选值"
|
||||
className="quick-field-input"
|
||||
value={text}
|
||||
disabled={!selectedKey}
|
||||
placeholder={selectedKey ? `${findArchiveQuickField(selectedKey)?.label ?? ''},多个用逗号分隔` : '请输入'}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commit()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
const record = filters as Record<string, unknown>
|
||||
const values = selectedKey && Array.isArray(record[selectedKey]) ? (record[selectedKey] as unknown[]) : []
|
||||
setText(values.map(String).join(','))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<div className="quick-field-panel" role="listbox" aria-label="选择筛选字段">
|
||||
<div className="quick-field-panel-search">
|
||||
<Search size={13} strokeWidth={1.8} />
|
||||
<input
|
||||
autoFocus
|
||||
value={fieldSearch}
|
||||
placeholder="搜索字段名"
|
||||
onChange={(event) => setFieldSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && visibleFields.length > 0) {
|
||||
event.preventDefault()
|
||||
chooseField(visibleFields[0].key)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fieldSearch && (
|
||||
<button type="button" aria-label="清空字段搜索" onClick={() => setFieldSearch('')}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-field-panel-list">
|
||||
{visibleFields.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.key === selectedKey}
|
||||
className={item.key === selectedKey ? 'is-selected' : ''}
|
||||
onClick={() => chooseField(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
{visibleFields.length === 0 && <div className="quick-field-panel-empty">没有匹配的字段</div>}
|
||||
</div>
|
||||
{/* 底部固定:多项筛选入口,不随字段列表滚动 */}
|
||||
<div className="quick-field-panel-footer">
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-field-multi ${activeQuickCount > 0 ? 'has-active' : ''}`}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setMultiOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className="quick-field-multi-label">
|
||||
多项筛选{activeQuickCount > 0 ? `(${activeQuickCount})` : ''}
|
||||
</span>
|
||||
<span className="quick-field-multi-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{multiOpen && (
|
||||
<ProductArchiveMultiFieldDialog
|
||||
filters={filters}
|
||||
onConfirm={(nextFilters) => {
|
||||
onChange(nextFilters)
|
||||
setMultiOpen(false)
|
||||
}}
|
||||
onCancel={() => setMultiOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.quick-field-filter { position: relative; display: inline-flex; align-items: center; }
|
||||
.quick-field-select {
|
||||
display: inline-flex; align-items: center; justify-content: space-between; gap: 4px;
|
||||
height: 30px; width: 104px; padding: 0 7px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px 0 0 4px; border-right: none;
|
||||
background: #fff; color: #27272a; font-size: 12px; cursor: pointer; outline: none;
|
||||
}
|
||||
.quick-field-select:focus { border-color: #52525b; }
|
||||
.quick-field-select.has-multi-active { color: #2563eb; }
|
||||
.quick-field-select :global(.is-open) { transform: rotate(180deg); }
|
||||
.quick-field-select-value {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.quick-field-input {
|
||||
height: 30px; width: 170px; padding: 0 8px;
|
||||
border: 1px solid #d4d4d8; border-radius: 0 4px 4px 0;
|
||||
background: #fff; color: #27272a; font-size: 12px; outline: none;
|
||||
}
|
||||
.quick-field-input:focus { border-color: #52525b; }
|
||||
.quick-field-input:disabled { background: #fafafa; color: #a1a1aa; cursor: not-allowed; }
|
||||
.quick-field-panel {
|
||||
position: absolute; top: calc(100% + 7px); left: 0; z-index: 40;
|
||||
display: flex; flex-direction: column;
|
||||
width: 220px; padding: 8px;
|
||||
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.quick-field-panel-search {
|
||||
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 7px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px; color: #a1a1aa;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.quick-field-panel-search:focus-within { border-color: #52525b; }
|
||||
.quick-field-panel-search input {
|
||||
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
|
||||
color: #27272a; font-size: 12px;
|
||||
}
|
||||
.quick-field-panel-search button {
|
||||
display: inline-flex; padding: 0; border: none; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.quick-field-panel-list {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
margin-top: 6px; max-height: 264px; overflow-y: auto; min-height: 120px;
|
||||
}
|
||||
.quick-field-panel-list button {
|
||||
display: flex; align-items: center; min-height: 28px; padding: 2px 7px;
|
||||
border: none; border-radius: 4px; background: transparent;
|
||||
color: #3f3f46; font-size: 12px; text-align: left; cursor: pointer;
|
||||
}
|
||||
.quick-field-panel-list button:hover { background: #f4f4f5; }
|
||||
.quick-field-panel-list button.is-selected { background: #f4f4f5; color: #18181b; font-weight: 600; }
|
||||
.quick-field-panel-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px 0; color: #a1a1aa; font-size: 12px;
|
||||
}
|
||||
.quick-field-panel-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 6px; padding-top: 7px; border-top: 1px solid #e4e4e7;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.quick-field-multi {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 26px; padding: 0 3px 0 7px;
|
||||
border: none; border-radius: 4px; background: transparent;
|
||||
color: #52525b; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.quick-field-multi:hover { background: #f4f4f5; color: #27272a; }
|
||||
.quick-field-multi.has-active { color: #2563eb; }
|
||||
.quick-field-multi-knob {
|
||||
position: relative; width: 28px; height: 15px; border-radius: 999px;
|
||||
background: #d4d4d8; transition: background 0.15s;
|
||||
}
|
||||
.quick-field-multi-knob::after {
|
||||
content: ''; position: absolute; top: 1.5px; left: 1.5px;
|
||||
width: 12px; height: 12px; border-radius: 999px; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); transition: left 0.15s;
|
||||
}
|
||||
.quick-field-multi.has-active .quick-field-multi-knob { background: #2563eb; }
|
||||
.quick-field-multi.has-active .quick-field-multi-knob::after { left: 14.5px; }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -53,8 +53,9 @@ export function ProductArchiveSalesPeriodFilter({ filters, period, loading, onCh
|
||||
</span>
|
||||
)}
|
||||
<style jsx>{`
|
||||
.sales-period { display:flex; flex-wrap:wrap; align-items:center; gap:8px; padding:10px 16px; background:#fff; border-bottom:1px solid #e4e4e7; flex-shrink:0; }
|
||||
.label { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; color:#3f3f46; }
|
||||
/* 并入顶部操作行后的紧凑形态:不带自己的卡片底,作为行内一截。 */
|
||||
.sales-period { display:inline-flex; flex-wrap:wrap; align-items:center; gap:8px; min-width:0; }
|
||||
.label { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; color:#3f3f46; white-space:nowrap; }
|
||||
.tabs { display:inline-flex; padding:2px; background:#f4f4f5; border-radius:5px; }
|
||||
.tabs button { width:36px; height:28px; padding:0; border:0; border-radius:4px; color:#52525b; background:transparent; cursor:pointer; }
|
||||
.tabs button[aria-pressed=true] { color:#fff; background:#27272a; }
|
||||
|
||||
@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { BookmarkPlus, X } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import type { ArchiveSavedQuery } from './useProductArchiveToolbarPreferences'
|
||||
|
||||
/** 键序无关的序列化:判断「当前筛选」是否就是某个已存快捷查询。 */
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null'
|
||||
}
|
||||
|
||||
/**
|
||||
* 「另存为快捷查询」+ 快捷查询胶囊:
|
||||
* 把当前筛选组合存成命名胶囊,点击一键套用;悬停可删除。
|
||||
*/
|
||||
export function ProductArchiveSavedQueries({
|
||||
queries,
|
||||
currentFilters,
|
||||
canSave,
|
||||
onApply,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
queries: ArchiveSavedQuery[]
|
||||
currentFilters: ProductArchiveFilters
|
||||
canSave: boolean
|
||||
onApply: (filters: ProductArchiveFilters) => void
|
||||
onSave: (name: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
||||
}, [open])
|
||||
|
||||
const currentKey = stableStringify(currentFilters)
|
||||
|
||||
const submit = () => {
|
||||
if (!name.trim() || !canSave) return
|
||||
onSave(name)
|
||||
setName('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="saved-queries" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`saved-queries-save ${open ? 'is-open' : ''}`}
|
||||
disabled={!canSave}
|
||||
title={canSave ? '把当前筛选另存为快捷查询' : '先设置筛选条件,才能另存快捷查询'}
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setName('')
|
||||
setOpen((current) => !current)
|
||||
}}
|
||||
>
|
||||
<BookmarkPlus size={14} strokeWidth={1.8} />
|
||||
另存查询
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="saved-queries-popover" role="dialog" aria-label="另存为快捷查询">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
maxLength={24}
|
||||
placeholder="快捷查询名称,如:在售钢架桌"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}}
|
||||
/>
|
||||
<button type="button" disabled={!name.trim()} onClick={submit}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queries.length > 0 && (
|
||||
<span className="saved-queries-chips">
|
||||
{queries.map((query) => {
|
||||
const active = stableStringify(query.filters) === currentKey
|
||||
return (
|
||||
<span key={query.id} className={`saved-query-chip ${active ? 'active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
title={`${query.name}(点击套用该筛选)`}
|
||||
onClick={() => onApply(query.filters)}
|
||||
>
|
||||
{query.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="saved-query-chip-delete"
|
||||
aria-label={`删除快捷查询:${query.name}`}
|
||||
title="删除该快捷查询"
|
||||
onClick={() => onDelete(query.id)}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.saved-queries { position: relative; display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.saved-queries-save {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
height: 30px; padding: 0 11px;
|
||||
background: #fff; border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
color: #3f3f46; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.saved-queries-save:hover:not(:disabled) { background: #f4f4f5; border-color: #a1a1aa; }
|
||||
.saved-queries-save:disabled { color: #a1a1aa; cursor: not-allowed; }
|
||||
.saved-queries-save.is-open { border-color: #52525b; }
|
||||
.saved-queries-popover {
|
||||
position: absolute; top: calc(100% + 7px); left: 0; z-index: 40;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
width: 250px; padding: 10px;
|
||||
background: #fff; border: 1px solid #d4d4d8; border-radius: 6px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.saved-queries-popover input {
|
||||
min-width: 0; flex: 1; height: 30px; padding: 0 8px;
|
||||
border: 1px solid #d4d4d8; border-radius: 4px;
|
||||
color: #27272a; font-size: 12px; outline: none;
|
||||
}
|
||||
.saved-queries-popover input:focus { border-color: #52525b; }
|
||||
.saved-queries-popover button {
|
||||
height: 30px; padding: 0 12px; flex: 0 0 auto;
|
||||
border: 1px solid #27272a; border-radius: 4px;
|
||||
background: #27272a; color: #fff; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.saved-queries-popover button:disabled { background: #a1a1aa; border-color: #a1a1aa; cursor: not-allowed; }
|
||||
.saved-queries-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.saved-query-chip {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
height: 26px; padding: 0 3px 0 0;
|
||||
background: #fff; border: 1px solid #e4e4e7; border-radius: 999px;
|
||||
}
|
||||
.saved-query-chip.active { border-color: #2563eb; background: rgba(37, 99, 235, 0.06); }
|
||||
.saved-query-chip > button:first-child {
|
||||
height: 100%; padding: 0 10px; border: none; border-radius: 999px;
|
||||
background: transparent; color: #3f3f46; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.saved-query-chip > button:first-child:hover { color: #18181b; }
|
||||
.saved-query-chip.active > button:first-child { color: #1d4ed8; font-weight: 600; }
|
||||
.saved-query-chip-delete {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; padding: 0; margin-right: 3px;
|
||||
border: none; border-radius: 999px; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.saved-query-chip-delete:hover { background: #e4e4e7; color: #b91c1c; }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,533 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LoaderCircle, Search, X } from 'lucide-react'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import type { ApiResponse } from '@/server/core/types'
|
||||
import {
|
||||
PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE,
|
||||
type ProductArchiveFilterOptions,
|
||||
type ProductArchiveFilters,
|
||||
} from '@/lib/supply-chain-product-archive'
|
||||
import type { ArchiveToolbarField } from './productArchiveToolbarFields'
|
||||
import { fetchArchiveResource } from './archiveRequest'
|
||||
|
||||
/** 候选总量低于该值走「全量+拼音分组」模式;高于则走「服务端搜索」模式。 */
|
||||
const LOCAL_MODE_MAX_TOTAL = 800
|
||||
const PAGE_SIZE = 1000
|
||||
const INDEX_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '#']
|
||||
|
||||
type LoadState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'local'; values: string[] }
|
||||
| { kind: 'server'; values: string[]; total: number | null; hasMore: boolean; searched: boolean }
|
||||
| { kind: 'static'; values: string[] }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
/** 取值的分组键:拼音首字母大写;非字母开头归「#」。 */
|
||||
function groupLetterOf(value: string): string {
|
||||
const first = String(value ?? '').trim().charAt(0)
|
||||
if (!first) return '#'
|
||||
if (/[a-zA-Z]/.test(first)) return first.toUpperCase()
|
||||
const letter = pinyin(first, { pattern: 'first', toneType: 'none', type: 'array' })[0] ?? ''
|
||||
return /^[a-zA-Z]$/.test(letter) ? letter.toUpperCase() : '#'
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚水潭式「选择面板」大弹窗:页面居中、搜索 + 全选/反选/清空 +
|
||||
* 拼音首字母分组多列网格 + A-Z 索引 + 取消/确定。
|
||||
* 小候选集(人名/枚举/供应商等)全量拉取本地分组;超大候选集(SPU/条码等)
|
||||
* 以服务端搜索为主。静态候选(在职状态等)直接列表。
|
||||
*/
|
||||
export function ProductArchiveSelectionDialog({
|
||||
field,
|
||||
filters,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
field: ArchiveToolbarField
|
||||
filters: ProductArchiveFilters
|
||||
onConfirm: (values: string[]) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>(() => {
|
||||
const record = filters as Record<string, unknown>
|
||||
const values = record[field.key]
|
||||
return Array.isArray(values) ? (values as unknown[]).map(String) : []
|
||||
})
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [state, setState] = useState<LoadState>(() =>
|
||||
field.staticOptions ? { kind: 'static', values: [...field.staticOptions] } : { kind: 'loading' },
|
||||
)
|
||||
const requestIdRef = useRef(0)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const requestPage = useCallback(
|
||||
async (offset: number, q: string) => {
|
||||
const params = new URLSearchParams({
|
||||
field: field.key,
|
||||
offset: String(offset),
|
||||
limit: String(PAGE_SIZE),
|
||||
})
|
||||
if (q) params.set('q', q)
|
||||
if (Object.keys(filters).length) params.set('filters', JSON.stringify(filters))
|
||||
const response = await fetchArchiveResource('/api/supply-chain/product-archive/options', params)
|
||||
const payload = (await response.json()) as ApiResponse<ProductArchiveFilterOptions>
|
||||
if (!response.ok || !payload.success || !payload.data) {
|
||||
throw new Error(payload.error?.message || '候选值加载失败')
|
||||
}
|
||||
return payload.data
|
||||
},
|
||||
[field.key, filters],
|
||||
)
|
||||
|
||||
// 打开时先探一页:总量可承受就拉全量(本地分组),否则进入服务端搜索模式。
|
||||
useEffect(() => {
|
||||
if (field.staticOptions) return
|
||||
const requestId = ++requestIdRef.current
|
||||
void (async () => {
|
||||
try {
|
||||
const first = await requestPage(0, '')
|
||||
if (requestId !== requestIdRef.current) return
|
||||
if (first.total !== null && first.total > LOCAL_MODE_MAX_TOTAL) {
|
||||
setState({ kind: 'server', values: [], total: first.total, hasMore: false, searched: false })
|
||||
return
|
||||
}
|
||||
const collected = [...first.values]
|
||||
let offset = first.values.length
|
||||
let hasMore = first.hasMore
|
||||
for (let page = 0; hasMore && page < 50; page += 1) {
|
||||
const next = await requestPage(offset, '')
|
||||
if (requestId !== requestIdRef.current) return
|
||||
collected.push(...next.values)
|
||||
offset += next.values.length
|
||||
hasMore = next.hasMore
|
||||
}
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setState({ kind: 'local', values: [...new Set(collected)].filter((v) => v !== PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE) })
|
||||
} catch (error) {
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setState({ kind: 'error', message: error instanceof Error ? error.message : '候选值加载失败' })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
requestIdRef.current += 1
|
||||
}
|
||||
}, [field, requestPage])
|
||||
|
||||
// 服务端搜索模式:停顿后按关键词查一页。
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'server') return
|
||||
const requestId = ++requestIdRef.current
|
||||
const timer = window.setTimeout(() => {
|
||||
setState((current) =>
|
||||
current.kind === 'server'
|
||||
? { ...current, values: [], hasMore: false, searched: false }
|
||||
: current,
|
||||
)
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await requestPage(0, searchText.trim())
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setState((current) =>
|
||||
current.kind === 'server'
|
||||
? { ...current, values: data.values, total: data.total, hasMore: data.hasMore, searched: true }
|
||||
: current,
|
||||
)
|
||||
} catch {
|
||||
// 搜索失败保持空结果,用户可重试。
|
||||
}
|
||||
})()
|
||||
}, 350)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchText, state.kind, requestPage])
|
||||
|
||||
// Esc 关闭、回车在搜索框内触发服务端模式立即搜索(防抖已覆盖,回车不额外做事)。
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onCancel()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
const viewValues = useMemo(() => {
|
||||
if (state.kind === 'static') return state.values
|
||||
if (state.kind === 'local') {
|
||||
const keyword = searchText.trim().toLowerCase()
|
||||
if (!keyword) return state.values
|
||||
return state.values.filter((value) => value.toLowerCase().includes(keyword))
|
||||
}
|
||||
if (state.kind === 'server') return state.values
|
||||
return []
|
||||
}, [state, searchText])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (state.kind === 'server' || state.kind === 'static') return []
|
||||
const map = new Map<string, string[]>()
|
||||
for (const value of viewValues) {
|
||||
const letter = groupLetterOf(value)
|
||||
const bucket = map.get(letter)
|
||||
if (bucket) bucket.push(value)
|
||||
else map.set(letter, [value])
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort(([left], [right]) => (left === '#' ? 1 : right === '#' ? -1 : left.localeCompare(right)))
|
||||
.map(([letter, values]) => ({
|
||||
letter,
|
||||
values: values.sort((left, right) => left.localeCompare(right, 'zh')),
|
||||
}))
|
||||
}, [state.kind, viewValues])
|
||||
|
||||
const availableLetters = useMemo(() => new Set(groups.map((group) => group.letter)), [groups])
|
||||
|
||||
const jumpToLetter = (letter: string) => {
|
||||
const body = bodyRef.current
|
||||
if (!body) return
|
||||
const target = body.querySelector<HTMLElement>(`[data-group-letter="${letter}"]`)
|
||||
if (target) body.scrollTo({ top: target.offsetTop - body.offsetTop - 4, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const toggle = (value: string) => {
|
||||
setSelected((current) =>
|
||||
current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
const selectAllInView = () => {
|
||||
setSelected((current) => [...new Set([...current, ...viewValues])])
|
||||
}
|
||||
|
||||
const invertInView = () => {
|
||||
setSelected((current) => {
|
||||
const viewSet = new Set(viewValues)
|
||||
const kept = current.filter((item) => !viewSet.has(item))
|
||||
return [...kept, ...viewValues.filter((item) => !current.includes(item))]
|
||||
})
|
||||
}
|
||||
|
||||
const clearAll = () => setSelected([])
|
||||
|
||||
const confirm = () => onConfirm(selected)
|
||||
|
||||
const loading = state.kind === 'loading'
|
||||
const isServerMode = state.kind === 'server'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="selection-dialog-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`选择${field.label}`}
|
||||
onPointerDown={(event) => {
|
||||
if (event.target === event.currentTarget) onCancel()
|
||||
}}
|
||||
>
|
||||
<div className="selection-dialog">
|
||||
<div className="selection-dialog-head">
|
||||
<span className="selection-dialog-title">选择{field.label}</span>
|
||||
<button type="button" className="selection-dialog-close" aria-label="关闭" onClick={onCancel}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="selection-dialog-toolbar">
|
||||
<div className="selection-dialog-search">
|
||||
<Search size={14} strokeWidth={1.8} />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={
|
||||
isServerMode
|
||||
? `输入关键词搜索${field.label}(候选较多,回车或停顿后搜索)`
|
||||
: `搜索${field.label}`
|
||||
}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
{searchText && (
|
||||
<button type="button" aria-label="清空搜索" onClick={() => setSearchText('')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!loading && state.kind !== 'error' && (
|
||||
<div className="selection-dialog-bulk">
|
||||
<button type="button" onClick={selectAllInView} disabled={viewValues.length === 0}>全选</button>
|
||||
<button type="button" onClick={invertInView} disabled={viewValues.length === 0}>反选</button>
|
||||
<button type="button" onClick={clearAll} disabled={selected.length === 0}>清空</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="selection-dialog-selected">
|
||||
{selected.slice(0, 12).map((value) => (
|
||||
<span key={value} className="selection-dialog-chip">
|
||||
{value}
|
||||
<button type="button" aria-label={`移除${value}`} onClick={() => toggle(value)}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selected.length > 12 && <span className="selection-dialog-chip-more">+{selected.length - 12}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="selection-dialog-body-wrap">
|
||||
<div className="selection-dialog-body" ref={bodyRef}>
|
||||
{loading && (
|
||||
<div className="selection-dialog-state">
|
||||
<LoaderCircle size={16} className="selection-dialog-spin" />
|
||||
正在加载候选值...
|
||||
</div>
|
||||
)}
|
||||
{state.kind === 'error' && <div className="selection-dialog-state error">{state.message}</div>}
|
||||
|
||||
{state.kind === 'static' && (
|
||||
<div className="selection-dialog-grid static">
|
||||
{viewValues.map((value) => (
|
||||
<SelectionOption key={value} value={value} checked={selected.includes(value)} onToggle={toggle} />
|
||||
))}
|
||||
{viewValues.length === 0 && <div className="selection-dialog-state">没有匹配的候选值</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.kind === 'local' && (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<div key={group.letter} className="selection-dialog-group" data-group-letter={group.letter}>
|
||||
<div className="selection-dialog-group-letter">{group.letter}</div>
|
||||
<div className="selection-dialog-grid">
|
||||
{group.values.map((value) => (
|
||||
<SelectionOption key={value} value={value} checked={selected.includes(value)} onToggle={toggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{viewValues.length === 0 && <div className="selection-dialog-state">没有匹配的候选值</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'server' && (
|
||||
<>
|
||||
{!state.searched && !searchText.trim() && (
|
||||
<div className="selection-dialog-state">
|
||||
{field.label}候选较多({state.total ?? '…'} 项),请输入关键词搜索后再勾选
|
||||
</div>
|
||||
)}
|
||||
{state.searched && (
|
||||
<>
|
||||
<div className="selection-dialog-server-summary">
|
||||
{state.total !== null ? `匹配 ${state.total} 项` : `已加载 ${state.values.length} 项`}
|
||||
<button
|
||||
type="button"
|
||||
className="selection-dialog-select-matched"
|
||||
disabled={state.values.length === 0}
|
||||
onClick={selectAllInView}
|
||||
>
|
||||
全选匹配项
|
||||
</button>
|
||||
</div>
|
||||
<div className="selection-dialog-grid static">
|
||||
{viewValues.map((value) => (
|
||||
<SelectionOption key={value} value={value} checked={selected.includes(value)} onToggle={toggle} />
|
||||
))}
|
||||
{viewValues.length === 0 && <div className="selection-dialog-state">没有匹配的候选值</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.kind === 'local' && groups.length > 0 && (
|
||||
<div className="selection-dialog-index">
|
||||
{INDEX_LETTERS.map((letter) => (
|
||||
<button
|
||||
key={letter}
|
||||
type="button"
|
||||
className={availableLetters.has(letter) ? 'available' : ''}
|
||||
disabled={!availableLetters.has(letter)}
|
||||
onClick={() => jumpToLetter(letter)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="selection-dialog-footer">
|
||||
<span className="selection-dialog-count">已选 {selected.length} 项</span>
|
||||
<div className="selection-dialog-footer-buttons">
|
||||
<button type="button" className="cancel" onClick={onCancel}>取消</button>
|
||||
<button type="button" className="confirm" onClick={confirm}>
|
||||
确定{selected.length > 0 ? `(${selected.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.selection-dialog-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(24, 24, 27, 0.45);
|
||||
padding: 24px;
|
||||
}
|
||||
.selection-dialog {
|
||||
display: flex; flex-direction: column;
|
||||
width: 880px; max-width: 100%; height: 560px; max-height: 100%;
|
||||
background: #fff; border-radius: 8px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
.selection-dialog-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid #f4f4f5; flex: 0 0 auto;
|
||||
}
|
||||
.selection-dialog-title { font-size: 14px; font-weight: 700; color: #18181b; }
|
||||
.selection-dialog-close {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; padding: 0; border: none; border-radius: 5px;
|
||||
background: transparent; color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-close:hover { background: #f4f4f5; color: #52525b; }
|
||||
.selection-dialog-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px 0; flex: 0 0 auto;
|
||||
}
|
||||
.selection-dialog-search {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1; max-width: 360px;
|
||||
height: 32px; padding: 0 9px;
|
||||
border: 1px solid #d4d4d8; border-radius: 5px; color: #a1a1aa;
|
||||
}
|
||||
.selection-dialog-search:focus-within { border-color: #52525b; }
|
||||
.selection-dialog-search input {
|
||||
min-width: 0; flex: 1; height: 100%; padding: 0; border: none; outline: none;
|
||||
color: #27272a; font-size: 13px;
|
||||
}
|
||||
.selection-dialog-search button {
|
||||
display: inline-flex; padding: 0; border: none; background: transparent;
|
||||
color: #a1a1aa; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-bulk { display: inline-flex; gap: 6px; margin-left: auto; }
|
||||
.selection-dialog-bulk button {
|
||||
height: 30px; padding: 0 10px;
|
||||
border: 1px solid #d4d4d8; border-radius: 5px;
|
||||
background: #fff; color: #3f3f46; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-bulk button:hover:not(:disabled) { background: #f4f4f5; }
|
||||
.selection-dialog-bulk button:disabled { color: #a1a1aa; cursor: not-allowed; }
|
||||
.selection-dialog-selected {
|
||||
display: flex; flex-wrap: wrap; gap: 5px;
|
||||
padding: 8px 16px 0; flex: 0 0 auto;
|
||||
}
|
||||
.selection-dialog-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 22px; padding: 0 4px 0 8px;
|
||||
background: rgba(37, 99, 235, 0.07); border: 1px solid rgba(37, 99, 235, 0.35);
|
||||
border-radius: 999px; color: #1d4ed8; font-size: 12px;
|
||||
}
|
||||
.selection-dialog-chip button {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 15px; height: 15px; padding: 0; border: none; border-radius: 999px;
|
||||
background: transparent; color: #1d4ed8; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-chip button:hover { background: rgba(37, 99, 235, 0.15); }
|
||||
.selection-dialog-chip-more { color: #71717a; font-size: 12px; align-self: center; }
|
||||
.selection-dialog-body-wrap {
|
||||
display: flex; flex: 1 1 auto; min-height: 0;
|
||||
padding: 10px 8px 10px 16px;
|
||||
}
|
||||
.selection-dialog-body { flex: 1; min-width: 0; overflow-y: auto; padding-right: 8px; }
|
||||
.selection-dialog-index {
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
flex: 0 0 26px; gap: 1px; padding: 4px 2px;
|
||||
}
|
||||
.selection-dialog-index button {
|
||||
width: 18px; height: 16px; padding: 0;
|
||||
border: none; border-radius: 3px; background: transparent;
|
||||
color: #d4d4d8; font-size: 10px; font-weight: 700; line-height: 1; cursor: default;
|
||||
}
|
||||
.selection-dialog-index button.available { color: #52525b; cursor: pointer; }
|
||||
.selection-dialog-index button.available:hover { background: #2563eb; color: #fff; }
|
||||
.selection-dialog-group { margin-bottom: 4px; }
|
||||
.selection-dialog-group-letter {
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
padding: 4px 2px;
|
||||
color: #2563eb; font-size: 12px; font-weight: 800;
|
||||
background: #fff;
|
||||
}
|
||||
.selection-dialog-grid {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px 8px;
|
||||
}
|
||||
.selection-dialog-option {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
min-height: 30px; padding: 2px 6px; border-radius: 4px;
|
||||
color: #3f3f46; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-option:hover, .selection-dialog-option.is-checked { background: #f4f4f5; }
|
||||
.selection-dialog-option.is-checked { color: #18181b; font-weight: 550; }
|
||||
.selection-dialog-option span {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.selection-dialog-option input { width: 14px; height: 14px; margin: 0; flex: 0 0 auto; accent-color: #27272a; }
|
||||
.selection-dialog-state {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 60px 20px; color: #a1a1aa; font-size: 13px;
|
||||
}
|
||||
.selection-dialog-state.error { color: #b91c1c; }
|
||||
.selection-dialog-spin { animation: selection-dialog-spin 1s linear infinite; }
|
||||
@keyframes selection-dialog-spin { to { transform: rotate(360deg); } }
|
||||
.selection-dialog-server-summary {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 6px 2px 8px; color: #52525b; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.selection-dialog-select-matched {
|
||||
padding: 0; border: none; background: transparent;
|
||||
color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-select-matched:hover { text-decoration: underline; }
|
||||
.selection-dialog-select-matched:disabled { color: #a1a1aa; cursor: not-allowed; }
|
||||
.selection-dialog-footer {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 12px 16px; border-top: 1px solid #f4f4f5; flex: 0 0 auto;
|
||||
}
|
||||
.selection-dialog-count { color: #71717a; font-size: 12px; }
|
||||
.selection-dialog-footer-buttons { display: inline-flex; gap: 8px; }
|
||||
.selection-dialog-footer-buttons button {
|
||||
height: 32px; padding: 0 18px; border-radius: 5px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.selection-dialog-footer-buttons .cancel { border: 1px solid #d4d4d8; background: #fff; color: #52525b; }
|
||||
.selection-dialog-footer-buttons .cancel:hover { background: #f4f4f5; }
|
||||
.selection-dialog-footer-buttons .confirm { border: 1px solid #27272a; background: #27272a; color: #fff; }
|
||||
.selection-dialog-footer-buttons .confirm:hover { background: #3f3f46; }
|
||||
@media (max-width: 960px) {
|
||||
.selection-dialog { width: 100%; height: 90vh; }
|
||||
.selection-dialog-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionOption({
|
||||
value,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
value: string
|
||||
checked: boolean
|
||||
onToggle: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className={`selection-dialog-option ${checked ? 'is-checked' : ''}`}>
|
||||
<input type="checkbox" checked={checked} onChange={() => onToggle(value)} />
|
||||
<span title={value}>{value}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@ -92,6 +92,10 @@ function renderCell(
|
||||
return formatProductArchiveDateTime(row[prop])
|
||||
}
|
||||
|
||||
// 负责人/设计师列优先展示在职状态标注值(离职接管/已离职后缀),原始值不动
|
||||
if (prop === 'opsHead') return row.opsHeadDisplay ?? row.opsHead
|
||||
if (prop === 'designer') return row.designerDisplay ?? row.designer
|
||||
|
||||
return row[prop as keyof SupplyChainProductArchiveRow]
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Settings, ArrowLeft, ArrowRight, Download, FileDown, FilterX, GripVertical, RefreshCw, ScanSearch } from 'lucide-react'
|
||||
import { Settings, ArrowLeft, ArrowRight, ChevronDown, Download, FileDown, FilterX, GripVertical, MoreHorizontal, RefreshCw, ScanSearch, Upload } from 'lucide-react'
|
||||
import { GROUP_COLORS } from './columns'
|
||||
import { useColumnSettings } from './useColumnSettings'
|
||||
import { ColumnSettingsPanel } from './ColumnSettingsPanel'
|
||||
@ -20,6 +20,12 @@ import { useProductArchivePageImages } from './useProductArchivePageImages'
|
||||
import { useProductArchiveImageExport } from './useProductArchiveImageExport'
|
||||
import { ProductArchiveThemeStyles } from './ProductArchiveThemeStyles'
|
||||
import { ProductArchiveSalesPeriodFilter } from './ProductArchiveSalesPeriodFilter'
|
||||
import { ProductArchiveFilterBar } from './ProductArchiveFilterBar'
|
||||
import { ProductArchiveFilterSettings } from './ProductArchiveFilterSettings'
|
||||
import { ProductArchiveActionMenu, type ArchiveToolbarMenuItem } from './ProductArchiveActionMenu'
|
||||
import { useProductArchiveToolbarPreferences } from './useProductArchiveToolbarPreferences'
|
||||
import { useProductArchiveOwnerImport } from './useProductArchiveOwnerImport'
|
||||
import { ProductArchiveOwnerImportProgress } from './ProductArchiveOwnerImportProgress'
|
||||
import type {
|
||||
ProductArchiveFilters,
|
||||
SupplyChainProductArchiveRow,
|
||||
@ -38,12 +44,15 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
|
||||
setPageSize,
|
||||
setFilters,
|
||||
} = useProductArchiveViewPreferences()
|
||||
const toolbar = useProductArchiveToolbarPreferences()
|
||||
const [pageInput, setPageInput] = useState(String(currentPage))
|
||||
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
|
||||
const [showImageSearch, setShowImageSearch] = useState(false)
|
||||
const [detailRow, setDetailRow] = useState<SupplyChainProductArchiveRow | null>(null)
|
||||
const [changeLogBarcode, setChangeLogBarcode] = useState<string | null>(null)
|
||||
const queryFiltersAppliedRef = useRef(false)
|
||||
const ownerImportInputRef = useRef<HTMLInputElement>(null)
|
||||
const ownerImport = useProductArchiveOwnerImport(() => void reload())
|
||||
const { rows, pageInfo, salesPeriod, loading, error, reload } = useProductArchiveData(
|
||||
currentPage,
|
||||
pageSize,
|
||||
@ -138,6 +147,61 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
// 「更多」菜单里的占位入口:使用说明/数据字典(操作手册后续再补)。
|
||||
const [toolbarNotice, setToolbarNotice] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!toolbarNotice) return
|
||||
const timer = setTimeout(() => setToolbarNotice(null), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toolbarNotice])
|
||||
const showToolbarNotice = useCallback((message: string) => setToolbarNotice(message), [])
|
||||
|
||||
const importMenuItems: ArchiveToolbarMenuItem[] = [
|
||||
{
|
||||
key: 'import-owners',
|
||||
label: ownerImport.isImporting ? '导入负责人(进行中…)' : '导入负责人',
|
||||
icon: <Upload size={14} strokeWidth={1.8} />,
|
||||
disabled: ownerImport.isImporting,
|
||||
onClick: () => ownerImportInputRef.current?.click(),
|
||||
},
|
||||
{
|
||||
key: 'owner-template',
|
||||
label: '负责人模板',
|
||||
icon: <FileDown size={14} strokeWidth={1.8} />,
|
||||
href: '/api/supply-chain/product-archive/import/template',
|
||||
},
|
||||
]
|
||||
const exportMenuItems: ArchiveToolbarMenuItem[] = [
|
||||
{
|
||||
key: 'export-archive',
|
||||
label: creatingArchiveTask ? '导出资料(加入队列中…)' : '导出资料',
|
||||
icon: <FileDown size={14} strokeWidth={1.8} />,
|
||||
disabled: creatingArchiveTask,
|
||||
onClick: () => void createArchiveExportTask(),
|
||||
},
|
||||
{
|
||||
key: 'export-images',
|
||||
label: exporting ? '导出图片(导出中…)' : '导出图片',
|
||||
icon: <Download size={14} strokeWidth={1.8} />,
|
||||
disabled: exporting,
|
||||
onClick: () => void exportImages(),
|
||||
},
|
||||
]
|
||||
const moreMenuItems: ArchiveToolbarMenuItem[] = [
|
||||
{
|
||||
key: 'user-guide',
|
||||
label: '使用说明',
|
||||
hint: '待完善',
|
||||
onClick: () => showToolbarNotice('操作手册整理中,敬请期待'),
|
||||
},
|
||||
{
|
||||
key: 'data-dictionary',
|
||||
label: '数据字典',
|
||||
hint: '待完善',
|
||||
onClick: () => showToolbarNotice('数据字典整理中,敬请期待'),
|
||||
},
|
||||
]
|
||||
|
||||
const changeFilters = useCallback((nextFilters: ProductArchiveFilters) => {
|
||||
setFilters(nextFilters)
|
||||
setCurrentPage(1)
|
||||
@ -165,7 +229,41 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
|
||||
|
||||
return (
|
||||
<div className="archive-page">
|
||||
{/* 工具条 */}
|
||||
{/* 表头第一行:筛选区(可收起;收起后是一条细条,最右「设置 | 展开」) */}
|
||||
{!toolbar.collapsed ? (
|
||||
<ProductArchiveFilterBar
|
||||
filters={filters}
|
||||
onChange={changeFilters}
|
||||
preciseFields={toolbar.preciseFields}
|
||||
savedQueries={toolbar.savedQueries}
|
||||
onSaveQuery={(name) => toolbar.saveQuery(name, filters)}
|
||||
onDeleteQuery={toolbar.deleteQuery}
|
||||
onPreciseFieldsChange={toolbar.setPreciseFields}
|
||||
onCollapse={() => toolbar.setCollapsed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="archive-filterbar-collapsed">
|
||||
<span className="archive-filterbar-collapsed-label">
|
||||
筛选{activeFilterCount > 0 ? `(${activeFilterCount} 个条件生效中)` : '(未设置条件)'}
|
||||
</span>
|
||||
<div className="archive-filterbar-collapsed-tail">
|
||||
<ProductArchiveFilterSettings
|
||||
selected={toolbar.preciseFields}
|
||||
onChange={toolbar.setPreciseFields}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="archive-filterbar-expand"
|
||||
onClick={() => toolbar.setCollapsed(false)}
|
||||
>
|
||||
展开
|
||||
<ChevronDown size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表头第二行:清除筛选 + 销售统计 + 右侧操作 */}
|
||||
<div className="archive-toolbar">
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
@ -180,44 +278,52 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
|
||||
清除筛选 {activeFilterCount}
|
||||
</button>
|
||||
)}
|
||||
<ProductArchiveSalesPeriodFilter filters={filters} period={salesPeriod} loading={loading} onChange={changeFilters} />
|
||||
<div className="archive-toolbar-actions">
|
||||
{canDownload ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="archive-toolbar-action"
|
||||
disabled={creatingArchiveTask}
|
||||
onClick={() => void createArchiveExportTask()}
|
||||
>
|
||||
<FileDown size={14} strokeWidth={1.8} />
|
||||
{creatingArchiveTask ? '加入队列中...' : '导出资料'}
|
||||
</button>
|
||||
{archiveTaskMessage ? (
|
||||
<span className="self-center text-xs font-semibold text-[#047857]">{archiveTaskMessage}</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="archive-toolbar-action"
|
||||
disabled={exporting}
|
||||
onClick={() => void exportImages()}
|
||||
>
|
||||
<Download size={14} strokeWidth={1.8} />
|
||||
{exporting ? '导出中...' : '导出图片'}
|
||||
</button>
|
||||
</>
|
||||
{archiveTaskMessage ? (
|
||||
<span className="self-center text-xs font-semibold text-[#047857]">{archiveTaskMessage}</span>
|
||||
) : null}
|
||||
{toolbarNotice ? (
|
||||
<span className="self-center text-xs font-semibold text-[#52525b]">{toolbarNotice}</span>
|
||||
) : null}
|
||||
<button className="archive-toolbar-action" onClick={() => setShowImageSearch(true)}>
|
||||
<ScanSearch size={14} strokeWidth={1.8} />
|
||||
以图搜图
|
||||
</button>
|
||||
<ProductArchiveActionMenu label="导入" icon={<Upload size={14} strokeWidth={1.8} />} items={importMenuItems} />
|
||||
{canDownload ? (
|
||||
<ProductArchiveActionMenu label="导出" icon={<FileDown size={14} strokeWidth={1.8} />} items={exportMenuItems} />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="archive-toolbar-action"
|
||||
disabled={loading}
|
||||
title="重新加载当前页数据"
|
||||
onClick={reload}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={1.8} />
|
||||
刷新
|
||||
</button>
|
||||
<button className="archive-toolbar-action" onClick={() => setShowColumnSettings(true)}>
|
||||
<Settings size={14} strokeWidth={1.8} />
|
||||
列设置
|
||||
</button>
|
||||
<ProductArchiveActionMenu label="更多" icon={<MoreHorizontal size={14} strokeWidth={1.8} />} items={moreMenuItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductArchiveSalesPeriodFilter filters={filters} period={salesPeriod} loading={loading} onChange={changeFilters} />
|
||||
<input
|
||||
ref={ownerImportInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (file) void ownerImport.importFile(file)
|
||||
}}
|
||||
/>
|
||||
<ProductArchiveOwnerImportProgress state={ownerImport.state} onDismiss={ownerImport.dismiss} />
|
||||
|
||||
{/* 表格 */}
|
||||
<div ref={tableWrapRef} className="archive-table-wrap">
|
||||
@ -391,11 +497,28 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.archive-filterbar-collapsed {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 6px 16px; min-height: 36px;
|
||||
background: #fff; border: 1px solid #f4f4f5; border-radius: 8px;
|
||||
flex-shrink: 0; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
.archive-filterbar-collapsed-label { color: #71717a; font-size: 12px; font-weight: 600; }
|
||||
.archive-filterbar-collapsed-tail { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.archive-filterbar-expand {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 28px; padding: 0 8px;
|
||||
border: none; border-radius: 4px;
|
||||
background: transparent; color: #71717a;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.archive-filterbar-expand:hover { background: #f4f4f5; color: #27272a; }
|
||||
.archive-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f4f4f5;
|
||||
border-radius: 8px;
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ProductArchiveOwnerImportProgress,
|
||||
ProductArchiveOwnerImportStreamEvent,
|
||||
} from '@/lib/supply-chain-product-archive-owner-import'
|
||||
|
||||
function parseProgress(value: unknown): ProductArchiveOwnerImportProgress {
|
||||
if (!value || typeof value !== 'object') throw new Error('导入进度响应格式不正确')
|
||||
const progress = value as Record<string, unknown>
|
||||
if (!['processed', 'total', 'updated', 'skipped', 'failed'].every((key) => typeof progress[key] === 'number')) {
|
||||
throw new Error('导入进度响应格式不正确')
|
||||
}
|
||||
if (!Array.isArray(progress.errors) || !Array.isArray(progress.warnings)) {
|
||||
throw new Error('导入进度响应格式不正确')
|
||||
}
|
||||
return progress as ProductArchiveOwnerImportProgress
|
||||
}
|
||||
|
||||
function parseEvent(line: string): ProductArchiveOwnerImportStreamEvent {
|
||||
const value = JSON.parse(line) as Record<string, unknown>
|
||||
const progress = parseProgress(value.progress)
|
||||
if (value.type === 'progress' || value.type === 'complete') return { type: value.type, progress }
|
||||
if (value.type === 'error' && typeof value.message === 'string') {
|
||||
return { type: 'error', progress, message: value.message }
|
||||
}
|
||||
throw new Error('导入进度响应格式不正确')
|
||||
}
|
||||
|
||||
export async function readOwnerImportStream(
|
||||
response: Response,
|
||||
onEvent: (event: ProductArchiveOwnerImportStreamEvent) => void,
|
||||
) {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
throw new Error(payload?.error?.message || '导入请求失败')
|
||||
}
|
||||
if (!response.body) throw new Error('浏览器无法读取导入进度')
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let complete: ProductArchiveOwnerImportProgress | null = null
|
||||
const consume = (line: string) => {
|
||||
if (!line.trim()) return
|
||||
const event = parseEvent(line)
|
||||
onEvent(event)
|
||||
if (event.type === 'complete') complete = event.progress
|
||||
if (event.type === 'error') throw new Error(event.message)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
lines.forEach(consume)
|
||||
if (done) break
|
||||
}
|
||||
consume(buffer)
|
||||
if (!complete) throw new Error('导入连接提前结束,请查看已导入的数据')
|
||||
return complete
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import {
|
||||
ARCHIVE_PRECISE_FIELDS,
|
||||
ARCHIVE_QUICK_FIELD_OPTIONS,
|
||||
DEFAULT_ARCHIVE_PRECISE_FIELDS,
|
||||
normalizeArchivePreciseFields,
|
||||
splitArchiveFilterValues,
|
||||
} from './productArchiveToolbarFields'
|
||||
|
||||
describe('product archive toolbar fields', () => {
|
||||
it('puts SPU/barcode/supplier first in the default visible fields', () => {
|
||||
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS.slice(0, 3)).toEqual(['spu', 'barcode', 'supplier'])
|
||||
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS).toEqual([
|
||||
'spu', 'barcode', 'supplier',
|
||||
'designer', 'opsHead', 'opsDeptHead', 'opsDirector', 'purchaser', 'warehouseHead',
|
||||
'categoryL1', 'brand', 'productStatus', 'ownerEmploymentStatus',
|
||||
])
|
||||
})
|
||||
|
||||
it('gives owner employment status static candidates and others api candidates', () => {
|
||||
const employment = ARCHIVE_PRECISE_FIELDS.find((item) => item.key === 'ownerEmploymentStatus')
|
||||
expect(employment?.staticOptions).toEqual(['在职', '已离职', '未匹配', '未设置'])
|
||||
const designer = ARCHIVE_PRECISE_FIELDS.find((item) => item.key === 'designer')
|
||||
expect(designer?.staticOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('covers all quick-field keys and keeps sku/productName out of the precise pool', () => {
|
||||
const preciseKeys = ARCHIVE_PRECISE_FIELDS.map((item) => item.key)
|
||||
for (const key of ['sku', 'productName']) {
|
||||
expect(preciseKeys).not.toContain(key)
|
||||
}
|
||||
// SPU/条码/供应商既在点选框池也在「请选择」池
|
||||
for (const key of ['spu', 'barcode', 'supplier']) {
|
||||
expect(preciseKeys).toContain(key)
|
||||
expect(ARCHIVE_QUICK_FIELD_OPTIONS.some((item) => item.key === key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes precise fields by dedupe and pool membership', () => {
|
||||
expect(normalizeArchivePreciseFields(['designer', 'nope', 'designer', 'region'])).toEqual([
|
||||
'designer', 'region',
|
||||
])
|
||||
expect(normalizeArchivePreciseFields('bad')).toEqual([])
|
||||
})
|
||||
|
||||
it('splits multi-value input on CN/EN separators and dedupes case-insensitively', () => {
|
||||
expect(splitArchiveFilterValues('XG0022, xg0022; DNZ0411、GBSFY-SJ0380 第二个值')).toEqual([
|
||||
'XG0022',
|
||||
'DNZ0411',
|
||||
'GBSFY-SJ0380',
|
||||
'第二个值',
|
||||
])
|
||||
expect(splitArchiveFilterValues(' ,,,; ')).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
// 产品全量档案 —— 顶部表头(筛选区/操作区)字段池与纯函数。
|
||||
// key 必须是 ProductArchiveFilters 已支持、服务端已放行的筛选键;
|
||||
// 这里只做字段清单,不碰任何请求逻辑。
|
||||
|
||||
import { OWNER_EMPLOYMENT_STATUS_VALUES } from '@/lib/supply-chain-owner-employment'
|
||||
|
||||
export type ArchiveToolbarField = {
|
||||
key: string
|
||||
label: string
|
||||
/** 有值 = 弹窗用静态候选(不走 options 接口);无值 = 走 options 接口取候选。 */
|
||||
staticOptions?: readonly string[]
|
||||
}
|
||||
|
||||
function field(key: string, label: string, staticOptions?: readonly string[]): ArchiveToolbarField {
|
||||
return { key, label, staticOptions }
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部常显字段池(设置里可配显隐)。统一形态:点击弹「可搜索 + 可勾选」小弹窗,
|
||||
* 字段名内置在框占位里。模糊搜索框已覆盖 SPU/SKU/条码/商品名称/供应商,不再放外框。
|
||||
*/
|
||||
export const ARCHIVE_PRECISE_FIELDS: ArchiveToolbarField[] = [
|
||||
field('spu', 'SPU'),
|
||||
field('barcode', '条码'),
|
||||
field('supplier', '供应商'),
|
||||
field('designer', '设计师'),
|
||||
field('opsHead', '运营负责人'),
|
||||
field('opsDeptHead', '运营部门负责人'),
|
||||
field('opsDirector', '运营总负责人'),
|
||||
field('purchaser', '采购'),
|
||||
field('warehouseHead', '仓库负责人'),
|
||||
field('categoryL1', '一级类目'),
|
||||
field('brand', '品牌'),
|
||||
field('productStatus', '商品状态'),
|
||||
field('ownerEmploymentStatus', '在职状态', OWNER_EMPLOYMENT_STATUS_VALUES),
|
||||
field('backupSupplier', '替换供应商'),
|
||||
field('categoryL2', '二级类目'),
|
||||
field('region', '区域'),
|
||||
field('material', '材质'),
|
||||
field('color', '颜色'),
|
||||
field('productionType', '产品类型'),
|
||||
field('packType', '单双包'),
|
||||
field('productTag', '商品标签'),
|
||||
field('supplierType', '供应商类型'),
|
||||
field('settleType', '结算方式'),
|
||||
field('storageType', '入库/代发'),
|
||||
field('boardProvider', '板材提供方'),
|
||||
field('installDocId', '安装图纸/视频ID'),
|
||||
]
|
||||
|
||||
/** 默认常显框(SPU/条码/供应商置顶 + 运营链字段 + 高频点选框)。 */
|
||||
export const DEFAULT_ARCHIVE_PRECISE_FIELDS: string[] = [
|
||||
'spu', 'barcode', 'supplier',
|
||||
'designer', 'opsHead', 'opsDeptHead', 'opsDirector', 'purchaser', 'warehouseHead',
|
||||
'categoryL1', 'brand', 'productStatus', 'ownerEmploymentStatus',
|
||||
]
|
||||
|
||||
/** 「请选择+请输入」的字段池:全部可精准匹配的字段。 */
|
||||
export const ARCHIVE_QUICK_FIELD_OPTIONS: ArchiveToolbarField[] = [
|
||||
field('sku', 'SKU'),
|
||||
field('productName', '商品名称'),
|
||||
...ARCHIVE_PRECISE_FIELDS,
|
||||
]
|
||||
|
||||
const PRECISE_FIELD_KEY_SET: ReadonlySet<string> = new Set(ARCHIVE_PRECISE_FIELDS.map((item) => item.key))
|
||||
|
||||
/** 按清单顺序归一常显字段:去重、去掉池外键。 */
|
||||
export function normalizeArchivePreciseFields(values: unknown): string[] {
|
||||
const list = Array.isArray(values) ? values : []
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
for (const key of list) {
|
||||
if (typeof key !== 'string' || !PRECISE_FIELD_KEY_SET.has(key) || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
normalized.push(key)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function findArchiveQuickField(key: string): ArchiveToolbarField | undefined {
|
||||
return ARCHIVE_QUICK_FIELD_OPTIONS.find((item) => item.key === key)
|
||||
}
|
||||
|
||||
/** 输入框文本拆值:中英文逗号/分号/顿号/空白分隔,去空去重。 */
|
||||
export function splitArchiveFilterValues(text: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
const values: string[] = []
|
||||
for (const part of String(text ?? '').split(/[,,;;、\s]+/)) {
|
||||
const value = part.trim()
|
||||
if (!value) continue
|
||||
const dedupeKey = value.toUpperCase()
|
||||
if (seen.has(dedupeKey)) continue
|
||||
seen.add(dedupeKey)
|
||||
values.push(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type {
|
||||
ProductArchiveOwnerImportProgress,
|
||||
ProductArchiveOwnerImportStreamEvent,
|
||||
} from '@/lib/supply-chain-product-archive-owner-import'
|
||||
import { readOwnerImportStream } from './ownerImportStream'
|
||||
|
||||
export type ProductArchiveOwnerImportState = ProductArchiveOwnerImportProgress & {
|
||||
fileName: string
|
||||
status: 'preparing' | 'running' | 'complete' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const EMPTY: ProductArchiveOwnerImportProgress = {
|
||||
processed: 0,
|
||||
total: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
export function useProductArchiveOwnerImport(onCompleted: () => void) {
|
||||
const [state, setState] = useState<ProductArchiveOwnerImportState | null>(null)
|
||||
const isImporting = state?.status === 'preparing' || state?.status === 'running'
|
||||
const importFile = useCallback(async (file: File) => {
|
||||
if (isImporting) return
|
||||
setState({ ...EMPTY, fileName: file.name, status: 'preparing' })
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
try {
|
||||
const response = await fetch('/api/supply-chain/product-archive/import', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
await readOwnerImportStream(
|
||||
response,
|
||||
(event: ProductArchiveOwnerImportStreamEvent) => {
|
||||
setState({
|
||||
...event.progress,
|
||||
fileName: file.name,
|
||||
status: event.type === 'progress' ? 'running' : event.type,
|
||||
...(event.type === 'error' ? { message: event.message } : {}),
|
||||
})
|
||||
},
|
||||
)
|
||||
onCompleted()
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...EMPTY,
|
||||
...current,
|
||||
fileName: file.name,
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : '导入失败',
|
||||
}))
|
||||
}
|
||||
}, [isImporting, onCompleted])
|
||||
|
||||
return {
|
||||
state,
|
||||
isImporting,
|
||||
importFile,
|
||||
dismiss: () => setState((current) => (
|
||||
current?.status === 'preparing' || current?.status === 'running' ? current : null
|
||||
)),
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
parseProductArchiveToolbarPreferences,
|
||||
useProductArchiveToolbarPreferences,
|
||||
} from './useProductArchiveToolbarPreferences'
|
||||
|
||||
const STORAGE_KEY = 'supply_chain_product_archive_toolbar_v1'
|
||||
|
||||
describe('product archive toolbar preferences', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps only pool-known precise fields', () => {
|
||||
const preferences = parseProductArchiveToolbarPreferences(
|
||||
JSON.stringify({
|
||||
preciseFields: ['designer', 'spu', 'nope', 'designer', 'region'],
|
||||
quickSelects: ['brand'],
|
||||
collapsed: 'yes',
|
||||
}),
|
||||
)
|
||||
|
||||
// spu 在字段框池(置顶点选框)被保留;nope 被清掉;quickSelects 已废弃被忽略
|
||||
expect(preferences.preciseFields).toEqual(['designer', 'spu', 'region'])
|
||||
expect(preferences.collapsed).toBe(false)
|
||||
expect(preferences.savedQueries).toEqual([])
|
||||
expect('quickSelects' in preferences).toBe(false)
|
||||
})
|
||||
|
||||
it('drops saved queries without usable filters and caps the list', () => {
|
||||
const queries = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `q-${index}`,
|
||||
name: `查询${index}`,
|
||||
filters: index % 2 === 0 ? { spu: [`XG00${index}`] } : {},
|
||||
}))
|
||||
const preferences = parseProductArchiveToolbarPreferences(
|
||||
JSON.stringify({ savedQueries: queries }),
|
||||
)
|
||||
|
||||
// 25 条里 13 条有效(偶数下标带 spu),先过滤无效再按上限截断
|
||||
expect(preferences.savedQueries).toHaveLength(13)
|
||||
expect(preferences.savedQueries.every((query) => query.filters.spu?.length === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('persists precise fields, collapse and saved queries across remounts', async () => {
|
||||
const first = renderHook(() => useProductArchiveToolbarPreferences())
|
||||
|
||||
await waitFor(() => expect(first.result.current.hydrated).toBe(true))
|
||||
act(() => {
|
||||
first.result.current.setPreciseFields(['designer', 'purchaser', 'bogus'])
|
||||
first.result.current.setCollapsed(true)
|
||||
first.result.current.saveQuery('在售钢架', { productStatus: ['销售'], material: ['钢'] })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}')).toMatchObject({
|
||||
collapsed: true,
|
||||
preciseFields: ['designer', 'purchaser'],
|
||||
})
|
||||
})
|
||||
first.unmount()
|
||||
|
||||
const second = renderHook(() => useProductArchiveToolbarPreferences())
|
||||
await waitFor(() => expect(second.result.current.hydrated).toBe(true))
|
||||
expect(second.result.current.collapsed).toBe(true)
|
||||
expect(second.result.current.preciseFields).toEqual(['designer', 'purchaser'])
|
||||
expect(second.result.current.savedQueries).toHaveLength(1)
|
||||
expect(second.result.current.savedQueries[0].name).toBe('在售钢架')
|
||||
|
||||
act(() => {
|
||||
second.result.current.deleteQuery(second.result.current.savedQueries[0].id)
|
||||
})
|
||||
await waitFor(() => expect(second.result.current.savedQueries).toHaveLength(0))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import { parseProductArchiveFilters } from './useProductArchiveViewPreferences'
|
||||
import {
|
||||
DEFAULT_ARCHIVE_PRECISE_FIELDS,
|
||||
normalizeArchivePreciseFields,
|
||||
} from './productArchiveToolbarFields'
|
||||
|
||||
const STORAGE_KEY = 'supply_chain_product_archive_toolbar_v1'
|
||||
const MAX_SAVED_QUERIES = 20
|
||||
|
||||
export type ArchiveSavedQuery = {
|
||||
id: string
|
||||
name: string
|
||||
filters: ProductArchiveFilters
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ProductArchiveToolbarPreferences = {
|
||||
/** 顶部常显的字段框(设置里可配,默认运营链字段+四个高频点选框)。 */
|
||||
preciseFields: string[]
|
||||
/** 顶部筛选行是否收起。 */
|
||||
collapsed: boolean
|
||||
/** 另存的快捷查询,新保存在最前。 */
|
||||
savedQueries: ArchiveSavedQuery[]
|
||||
}
|
||||
|
||||
export const DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES: ProductArchiveToolbarPreferences = {
|
||||
preciseFields: [...DEFAULT_ARCHIVE_PRECISE_FIELDS],
|
||||
collapsed: false,
|
||||
savedQueries: [],
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function sanitizeSavedQueries(value: unknown): ArchiveSavedQuery[] {
|
||||
const list = Array.isArray(value) ? value : []
|
||||
const collected: ArchiveSavedQuery[] = []
|
||||
const seenIds = new Set<string>()
|
||||
for (const item of list) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === 'string' ? item.id : ''
|
||||
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
||||
if (!id || !name || seenIds.has(id)) continue
|
||||
const filters = parseProductArchiveFilters(JSON.stringify(item.filters ?? {}))
|
||||
if (Object.keys(filters).length === 0) continue
|
||||
seenIds.add(id)
|
||||
collected.push({
|
||||
id,
|
||||
name,
|
||||
filters,
|
||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
return collected.slice(0, MAX_SAVED_QUERIES)
|
||||
}
|
||||
|
||||
export function parseProductArchiveToolbarPreferences(raw: string | null): ProductArchiveToolbarPreferences {
|
||||
if (!raw) return DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
return {
|
||||
// 全部取消勾选是合法状态(顶部一个字段框都不摆),空数组保持空。
|
||||
preciseFields: normalizeArchivePreciseFields(parsed.preciseFields),
|
||||
collapsed: parsed.collapsed === true,
|
||||
savedQueries: sanitizeSavedQueries(parsed.savedQueries),
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES
|
||||
}
|
||||
}
|
||||
|
||||
export function useProductArchiveToolbarPreferences() {
|
||||
const [preferences, setPreferences] = useState<ProductArchiveToolbarPreferences>(
|
||||
DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES,
|
||||
)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setPreferences(parseProductArchiveToolbarPreferences(localStorage.getItem(STORAGE_KEY)))
|
||||
} finally {
|
||||
setHydrated(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))
|
||||
} catch {
|
||||
// 存储不可用时保留内存态。
|
||||
}
|
||||
}, [hydrated, preferences])
|
||||
|
||||
const setPreciseFields = useCallback((fields: string[]) => {
|
||||
setPreferences((current) => ({ ...current, preciseFields: normalizeArchivePreciseFields(fields) }))
|
||||
}, [])
|
||||
|
||||
const setCollapsed = useCallback((collapsed: boolean) => {
|
||||
setPreferences((current) => ({ ...current, collapsed }))
|
||||
}, [])
|
||||
|
||||
const saveQuery = useCallback((name: string, filters: ProductArchiveFilters) => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
setPreferences((current) => {
|
||||
const query: ArchiveSavedQuery = {
|
||||
id: `aq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: trimmed,
|
||||
filters,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
savedQueries: [query, ...current.savedQueries].slice(0, MAX_SAVED_QUERIES),
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteQuery = useCallback((id: string) => {
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
savedQueries: current.savedQueries.filter((query) => query.id !== id),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
hydrated,
|
||||
setPreciseFields,
|
||||
setCollapsed,
|
||||
saveQuery,
|
||||
deleteQuery,
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import {
|
||||
type ProductArchiveSalesStatus,
|
||||
type ProductArchiveTextFilterKey,
|
||||
} from '@/lib/supply-chain-product-archive'
|
||||
import { OWNER_EMPLOYMENT_STATUS_VALUES } from '@/lib/supply-chain-owner-employment'
|
||||
|
||||
const STORAGE_KEY = 'supply_chain_product_archive_view_v1'
|
||||
const PAGE_SIZES = [50, 100, 200] as const
|
||||
@ -97,6 +98,12 @@ function sanitizeFilters(value: unknown): ProductArchiveFilters {
|
||||
}
|
||||
}
|
||||
|
||||
// 「负责人在职状态」是虚拟筛选:取值固定为四个状态值
|
||||
const ownerEmploymentStatus = cleanStringArray(value.ownerEmploymentStatus)?.filter((item) =>
|
||||
(OWNER_EMPLOYMENT_STATUS_VALUES as readonly string[]).includes(item),
|
||||
)
|
||||
if (ownerEmploymentStatus?.length) filters.ownerEmploymentStatus = ownerEmploymentStatus
|
||||
|
||||
for (const key of ['createTimeFrom', 'createTimeTo', 'updateTimeFrom', 'updateTimeTo'] as const) {
|
||||
const normalized = cleanString(value[key])
|
||||
if (normalized) filters[key] = normalized
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import {
|
||||
buildOwnerEmploymentIndex,
|
||||
normalizeOwnerName,
|
||||
resolveDesignerView,
|
||||
resolveOwnerTakeover,
|
||||
type OwnerEmploymentEntry,
|
||||
} from './supply-chain-owner-employment'
|
||||
|
||||
function index(entries: Array<Partial<OwnerEmploymentEntry> & { name: string }>) {
|
||||
return buildOwnerEmploymentIndex(entries.map((entry) => ({
|
||||
sourceStatus: 'EMPLOYED' as const,
|
||||
departmentOwner: '',
|
||||
generalOwner: '',
|
||||
...entry,
|
||||
})))
|
||||
}
|
||||
|
||||
describe('负责人在职状态与离职接管链', () => {
|
||||
it('空负责人为未设置,匹配不到为未匹配,在职原样显示', () => {
|
||||
const idx = index([{ name: '半夏' }])
|
||||
expect(resolveOwnerTakeover('', idx)).toMatchObject({ status: '未设置', display: '' })
|
||||
expect(resolveOwnerTakeover(' ', idx)).toMatchObject({ status: '未设置' })
|
||||
expect(resolveOwnerTakeover('香茗', idx)).toMatchObject({ status: '未匹配', display: '香茗' })
|
||||
expect(resolveOwnerTakeover('半夏', idx)).toMatchObject({ status: '在职', display: '半夏' })
|
||||
})
|
||||
|
||||
it('花名按去零宽字符与大写口径匹配', () => {
|
||||
const idx = index([{ name: '五竹' }])
|
||||
expect(resolveOwnerTakeover('五\u200B竹', idx).status).toBe('在职')
|
||||
expect(normalizeOwnerName(' 五竹 ')).toBe('五竹')
|
||||
})
|
||||
|
||||
it('离职负责人转部门现任负责人', () => {
|
||||
const idx = index([
|
||||
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
|
||||
{ name: '半夏', departmentOwner: '半夏', generalOwner: '江陵' },
|
||||
{ name: '江陵', departmentOwner: '江陵', generalOwner: '江陵' },
|
||||
])
|
||||
expect(resolveOwnerTakeover('五竹', idx)).toEqual({
|
||||
status: '已离职',
|
||||
display: '五竹(已离职)→ 半夏',
|
||||
successor: '半夏',
|
||||
successorDeparted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('部门负责人也离职时转总负责人', () => {
|
||||
const idx = index([
|
||||
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
|
||||
{ name: '半夏', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
|
||||
{ name: '江陵', departmentOwner: '江陵', generalOwner: '江陵' },
|
||||
])
|
||||
expect(resolveOwnerTakeover('五竹', idx)).toEqual({
|
||||
status: '已离职',
|
||||
display: '五竹(已离职)→ 江陵',
|
||||
successor: '江陵',
|
||||
successorDeparted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('总负责人也离职时带标注展示,不再上抛', () => {
|
||||
const idx = index([
|
||||
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
|
||||
{ name: '半夏', sourceStatus: 'DIMISSIONED', departmentOwner: '半夏', generalOwner: '江陵' },
|
||||
{ name: '江陵', sourceStatus: 'DIMISSIONED', departmentOwner: '江陵', generalOwner: '江陵' },
|
||||
])
|
||||
const view = resolveOwnerTakeover('五竹', idx)
|
||||
expect(view.display).toBe('五竹(已离职)→ 江陵(已离职)')
|
||||
expect(view.successorDeparted).toBe(true)
|
||||
})
|
||||
|
||||
it('部门负责人缺位或为本人时直接取总负责人;全缺位时仅展示本人已离职', () => {
|
||||
const onlyGeneral = index([
|
||||
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '', generalOwner: '江陵' },
|
||||
{ name: '江陵', generalOwner: '江陵' },
|
||||
])
|
||||
expect(resolveOwnerTakeover('五竹', onlyGeneral).display).toBe('五竹(已离职)→ 江陵')
|
||||
|
||||
const selfOwner = index([
|
||||
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '五竹', generalOwner: '江陵' },
|
||||
{ name: '江陵', generalOwner: '江陵' },
|
||||
])
|
||||
expect(resolveOwnerTakeover('五竹', selfOwner).display).toBe('五竹(已离职)→ 江陵')
|
||||
|
||||
const noSuccessor = index([
|
||||
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '', generalOwner: '' },
|
||||
])
|
||||
expect(resolveOwnerTakeover('五竹', noSuccessor).display).toBe('五竹(已离职)')
|
||||
})
|
||||
|
||||
it('无员工目录关联的人员按在职处理(不标注、不接管)', () => {
|
||||
const idx = index([{ name: '手工行', sourceStatus: 'EMPLOYED' }])
|
||||
expect(resolveOwnerTakeover('手工行', idx).status).toBe('在职')
|
||||
})
|
||||
|
||||
it('设计师离职只加后缀,不接管;未匹配设计师原样', () => {
|
||||
const idx = index([
|
||||
{ name: '辛环', sourceStatus: 'DIMISSIONED' },
|
||||
{ name: '在册设计师' },
|
||||
])
|
||||
expect(resolveDesignerView('辛环', idx)).toEqual({ display: '辛环(已离职)', departed: true })
|
||||
expect(resolveDesignerView('在册设计师', idx)).toEqual({ display: '在册设计师', departed: false })
|
||||
expect(resolveDesignerView('外部自由设计师', idx)).toEqual({ display: '外部自由设计师', departed: false })
|
||||
expect(resolveDesignerView('', idx)).toEqual({ display: '', departed: false })
|
||||
})
|
||||
})
|
||||
107
furniture-3d-system/src/lib/supply-chain-owner-employment.ts
Normal file
107
furniture-3d-system/src/lib/supply-chain-owner-employment.ts
Normal file
@ -0,0 +1,107 @@
|
||||
// 负责人在职状态与离职接管链的共享口径(2026-10-12 用户确认)。
|
||||
//
|
||||
// 全量档案的运营负责人/设计师在职状态是「显示层」能力:库里永远存原始花名,
|
||||
// 页面与导出按人员信息表(SupplyChainManagementOwnerConfig)实时判断:
|
||||
// - 负责人在职 → 原样显示;
|
||||
// - 负责人离职 → `花名(已离职)→ 接管人`,接管人依次取其部门现任负责人、
|
||||
// 运营总负责人;接管人也离职时仅追加(已离职)标注,不再上抛;
|
||||
// - 花名匹配不到人员信息 → 原样显示,状态「未匹配」(需要补人员信息或改档案);
|
||||
// - 档案无负责人 → 状态「未设置」。
|
||||
// 设计师离职只加(已离职)标注,不做接管(设计师无层级链)。
|
||||
|
||||
export type OwnerEmploymentStatus = '在职' | '已离职' | '未匹配' | '未设置'
|
||||
|
||||
export const OWNER_EMPLOYMENT_STATUS_VALUES = [
|
||||
'在职',
|
||||
'已离职',
|
||||
'未匹配',
|
||||
'未设置',
|
||||
] as const satisfies readonly OwnerEmploymentStatus[]
|
||||
|
||||
export type OwnerEmploymentEntry = {
|
||||
/** 人员信息表花名(原样,不清洗) */
|
||||
name: string
|
||||
/** 在职状态;无员工目录关联的行按在职处理(不标注) */
|
||||
sourceStatus: 'EMPLOYED' | 'DIMISSIONED'
|
||||
departmentOwner: string
|
||||
generalOwner: string
|
||||
}
|
||||
|
||||
/** 与档案侧身份清洗同口径:去零宽字符 + 去首尾空白 + 大写。 */
|
||||
export function normalizeOwnerName(value: string) {
|
||||
return value.replace(/[\u200B-\u200D\uFEFF]/g, '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
export type OwnerEmploymentIndex = Map<string, OwnerEmploymentEntry>
|
||||
|
||||
/** 同名花名取第一条(调用方按 operationOwner asc、updatedAt desc 排序传入)。 */
|
||||
export function buildOwnerEmploymentIndex(entries: OwnerEmploymentEntry[]): OwnerEmploymentIndex {
|
||||
const index: OwnerEmploymentIndex = new Map()
|
||||
for (const entry of entries) {
|
||||
const key = normalizeOwnerName(entry.name)
|
||||
if (key && !index.has(key)) index.set(key, entry)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
export type OwnerTakeoverView = {
|
||||
status: OwnerEmploymentStatus
|
||||
/** 展示值:在职/未匹配/未设置为原样(未设置为空串),离职为 `花名(已离职)→ 接管人` */
|
||||
display: string
|
||||
/** 接管人花名(仅离职且有接管人时有值) */
|
||||
successor: string
|
||||
/** 接管人本身也已离职 */
|
||||
successorDeparted: boolean
|
||||
}
|
||||
|
||||
export function resolveOwnerTakeover(
|
||||
opsHead: string | null | undefined,
|
||||
index: OwnerEmploymentIndex,
|
||||
): OwnerTakeoverView {
|
||||
const raw = (opsHead ?? '').trim()
|
||||
if (!raw) return { status: '未设置', display: '', successor: '', successorDeparted: false }
|
||||
const entry = index.get(normalizeOwnerName(raw))
|
||||
if (!entry) return { status: '未匹配', display: raw, successor: '', successorDeparted: false }
|
||||
if (entry.sourceStatus !== 'DIMISSIONED') {
|
||||
return { status: '在职', display: raw, successor: '', successorDeparted: false }
|
||||
}
|
||||
|
||||
const ownerKey = normalizeOwnerName(raw)
|
||||
const pickSuccessor = (name: string | null | undefined) => {
|
||||
const trimmed = (name ?? '').trim()
|
||||
if (!trimmed || normalizeOwnerName(trimmed) === ownerKey) return null
|
||||
return {
|
||||
name: trimmed,
|
||||
departed: index.get(normalizeOwnerName(trimmed))?.sourceStatus === 'DIMISSIONED',
|
||||
}
|
||||
}
|
||||
const departmentOwner = pickSuccessor(entry.departmentOwner)
|
||||
const generalOwner = pickSuccessor(entry.generalOwner)
|
||||
// 接管链:部门现任负责人在职 → 直接接管;否则交给总负责人(总负责人也离职则
|
||||
// 带标注展示,不再上抛);两者都缺位时退回展示部门负责人(如有)。
|
||||
const successor = (departmentOwner && !departmentOwner.departed ? departmentOwner : null)
|
||||
?? generalOwner
|
||||
?? departmentOwner
|
||||
if (!successor) {
|
||||
return { status: '已离职', display: `${raw}(已离职)`, successor: '', successorDeparted: false }
|
||||
}
|
||||
return {
|
||||
status: '已离职',
|
||||
display: `${raw}(已离职)→ ${successor.name}${successor.departed ? '(已离职)' : ''}`,
|
||||
successor: successor.name,
|
||||
successorDeparted: successor.departed,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDesignerView(
|
||||
designer: string | null | undefined,
|
||||
index: OwnerEmploymentIndex,
|
||||
): { display: string; departed: boolean } {
|
||||
const raw = (designer ?? '').trim()
|
||||
if (!raw) return { display: '', departed: false }
|
||||
const entry = index.get(normalizeOwnerName(raw))
|
||||
if (entry?.sourceStatus === 'DIMISSIONED') {
|
||||
return { display: `${raw}(已离职)`, departed: true }
|
||||
}
|
||||
return { display: raw, departed: false }
|
||||
}
|
||||
@ -22,6 +22,7 @@ export const PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE = 'PRODUCT_CATALOG_COST_PR
|
||||
export const WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE = 'WAREHOUSE_CONFIG'
|
||||
export const SYSTEM_USER_DEPARTMENT_ARCHIVE_SOURCE_CODE = 'SYSTEM_USER_DEPARTMENT'
|
||||
export const JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE = 'JST_SKU_INFO_MANAGE'
|
||||
export const MANUAL_IMPORT_ARCHIVE_SOURCE_CODE = 'MANUAL_IMPORT'
|
||||
|
||||
export type ProductArchiveFieldSource = {
|
||||
sourceCode: string
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
// 产品全量档案「按条码上传运营负责人/设计师」导入的类型与常量。
|
||||
//
|
||||
// 口径(2026-10-12 用户确认):
|
||||
// - 模板三列:条码(必填)、运营负责人、设计师;空单元格不覆盖对应字段;
|
||||
// - 运营负责人严格校验:必须存在于人员信息(含已离职),否则该行失败;
|
||||
// - 设计师自由文本;
|
||||
// - 上传以 MANUAL_IMPORT 来源 + 上传时间戳落库,作为新的历史基准:
|
||||
// 早于上传时间的历史审批/聚水潭设计师回填不再覆盖,之后新通过的审批可再覆盖。
|
||||
|
||||
export const PRODUCT_ARCHIVE_OWNER_IMPORT_COLUMNS = ['条码', '运营负责人', '设计师'] as const
|
||||
|
||||
export type ProductArchiveOwnerImportRow = {
|
||||
/** Excel 实际行号(含表头行) */
|
||||
rowNumber: number
|
||||
barcode: string
|
||||
opsHead: string
|
||||
designer: string
|
||||
}
|
||||
|
||||
export type ProductArchiveOwnerImportIssue = {
|
||||
rowNumber: number
|
||||
barcode: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ProductArchiveOwnerImportProgress = {
|
||||
processed: number
|
||||
total: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
warnings: ProductArchiveOwnerImportIssue[]
|
||||
errors: ProductArchiveOwnerImportIssue[]
|
||||
}
|
||||
|
||||
export type ProductArchiveOwnerImportStreamEvent =
|
||||
| { type: 'progress'; progress: ProductArchiveOwnerImportProgress }
|
||||
| { type: 'complete'; progress: ProductArchiveOwnerImportProgress }
|
||||
| { type: 'error'; progress: ProductArchiveOwnerImportProgress; message: string }
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ArchiveSalesPeriod } from './product-archive-sales-period'
|
||||
import type { OwnerEmploymentStatus } from './supply-chain-owner-employment'
|
||||
|
||||
export type SupplyChainProductArchiveRow = {
|
||||
id: string
|
||||
@ -14,9 +15,15 @@ export type SupplyChainProductArchiveRow = {
|
||||
barcode: string
|
||||
productName: string
|
||||
designer: string
|
||||
/** 设计师在职状态展示值(离职时为 `花名(已离职)`);人员信息不可用时缺省回退 designer */
|
||||
designerDisplay?: string
|
||||
opsDirector: string
|
||||
opsDeptHead: string
|
||||
opsHead: string
|
||||
/** 运营负责人离职接管展示值(如 `五竹(已离职)→ 半夏`);缺省回退 opsHead */
|
||||
opsHeadDisplay?: string
|
||||
/** 运营负责人在职状态(按人员信息实时判定;未加载人员信息时缺省) */
|
||||
ownerEmploymentStatus?: OwnerEmploymentStatus
|
||||
productStatus: string
|
||||
productionType: string
|
||||
material: string
|
||||
@ -214,6 +221,8 @@ export type ProductArchiveFilters = {
|
||||
opsDirector?: string[]
|
||||
opsDeptHead?: string[]
|
||||
opsHead?: string[]
|
||||
/** 负责人在职状态(在职/已离职/未匹配/未设置),按人员信息实时判定的虚拟筛选 */
|
||||
ownerEmploymentStatus?: string[]
|
||||
warehouseHead?: string[]
|
||||
warehouseOwnership?: string[]
|
||||
warehouseType?: string[]
|
||||
|
||||
@ -73,4 +73,22 @@ describe('ProductArchiveOwnerWorkflowRepository', () => {
|
||||
expect(await new ProductArchiveOwnerWorkflowRepositoryClass().applyAssignment(assignment)).toBe('newer-source')
|
||||
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not overwrite a manual import baseline with an older approval', async () => {
|
||||
// 上传时间 2026-10-11,审批完成时间 2026-10-09:历史审批不覆盖上传基准
|
||||
const tx = setup('上传负责人', {
|
||||
sourceCode: 'MANUAL_IMPORT', sourceColumn: '运营负责人',
|
||||
sourceRecordId: 'BAR:2026-10-11T00:00:00.000Z', sourceUpdatedAt: '2026-10-11T00:00:00.000Z',
|
||||
})
|
||||
expect(await new ProductArchiveOwnerWorkflowRepositoryClass().applyAssignment(assignment)).toBe('newer-source')
|
||||
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()
|
||||
})
|
||||
it('lets an approval approved after the manual import override it', async () => {
|
||||
// 上传时间 2026-10-08,审批完成时间 2026-10-09:新审批正常覆盖
|
||||
const tx = setup('上传负责人', {
|
||||
sourceCode: 'MANUAL_IMPORT', sourceColumn: '运营负责人',
|
||||
sourceRecordId: 'BAR:2026-10-08T00:00:00.000Z', sourceUpdatedAt: '2026-10-08T00:00:00.000Z',
|
||||
})
|
||||
expect(await new ProductArchiveOwnerWorkflowRepositoryClass().applyAssignment(assignment)).toBe('changed')
|
||||
expect(tx.supplyChainProductArchive.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -143,28 +143,37 @@ describe('SupplyChainManagementOwnerConfigRepository', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('同步时保留离职人员原数据,只更新在职和新入职人员', async () => {
|
||||
it('同步时在职与已离职人员都建行/更新,离职行同样刷新层级', async () => {
|
||||
const items = [
|
||||
{ employeeId: 'active-employee', operationOwner: '在职员工', department: '新部门', secondaryDepartment: '新二级部门', profitCenter: '新中心', departmentOwner: '新主管', generalOwner: '新总负责人', employmentStatus: '在职' as const },
|
||||
{ employeeId: 'departed-employee', operationOwner: '离职员工', department: '已变化部门', secondaryDepartment: '已变化二级部门', profitCenter: '已变化中心', departmentOwner: '已变化主管', generalOwner: '已变化总负责人', employmentStatus: '离职' as const },
|
||||
]
|
||||
|
||||
await expect(supplyChainManagementOwnerConfigRepository.syncDirectoryConfigs(items))
|
||||
.resolves.toEqual({ synced: 1, removed: 0, preserved: 0 })
|
||||
.resolves.toEqual({ synced: 2, removed: 0, preserved: 0 })
|
||||
|
||||
expect(mockOwnerConfig.findMany).toHaveBeenCalledWith({
|
||||
where: { sourceEmployeeId: { in: ['active-employee'] } },
|
||||
where: { sourceEmployeeId: { in: ['active-employee', 'departed-employee'] } },
|
||||
select: { sourceEmployeeId: true, manualOverride: true, isHidden: true },
|
||||
})
|
||||
expect(mockOwnerConfig.upsert).toHaveBeenCalledTimes(1)
|
||||
expect(mockOwnerConfig.upsert).toHaveBeenCalledTimes(2)
|
||||
expect(mockOwnerConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { sourceEmployeeId: 'active-employee' },
|
||||
}))
|
||||
expect(mockOwnerConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { sourceEmployeeId: 'departed-employee' },
|
||||
update: expect.objectContaining({
|
||||
department: '已变化部门',
|
||||
departmentOwner: '已变化主管',
|
||||
generalOwner: '已变化总负责人',
|
||||
}),
|
||||
}))
|
||||
// 清理名单包含在职与离职两类员工 ID;DIMISSIONED 保护条件仍然保留
|
||||
expect(mockOwnerConfig.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
manualOverride: false,
|
||||
sourceEmployee: { is: { sourceStatus: { not: 'DIMISSIONED' } } },
|
||||
sourceEmployeeId: { notIn: ['active-employee'] },
|
||||
sourceEmployeeId: { notIn: ['active-employee', 'departed-employee'] },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
parseProductArchiveFieldSourceMap,
|
||||
toProductArchiveFieldSourceJson,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
import { MANUAL_IMPORT_ARCHIVE_SOURCE_CODE } from '@/lib/supply-chain-product-archive-field-source'
|
||||
import {
|
||||
OWNER_WORKFLOW_SOURCE_CODE,
|
||||
type OwnerWorkflowAssignment,
|
||||
@ -39,6 +40,10 @@ export class ProductArchiveOwnerWorkflowRepositoryClass implements ProductArchiv
|
||||
if (actualCode !== change.code) throw new Error(`Archive identity changed: ${previous.barcode}`)
|
||||
const sources = parseProductArchiveFieldSourceMap(previous.fieldSourceMap)
|
||||
const stored = sources.opsHead
|
||||
// 上传替换(MANUAL_IMPORT)是新的历史基准:审批完成时间不晚于上传时间的
|
||||
// 历史审批不再覆盖;晚于上传时间的新审批正常覆盖(2026-10-12 口径)。
|
||||
if (stored?.sourceCode === MANUAL_IMPORT_ARCHIVE_SOURCE_CODE &&
|
||||
Date.parse(stored.sourceUpdatedAt) >= Date.parse(change.completedAt)) return 'newer-source'
|
||||
if (stored?.sourceCode === OWNER_WORKFLOW_SOURCE_CODE &&
|
||||
Date.parse(stored.sourceUpdatedAt) > Date.parse(change.completedAt)) return 'newer-source'
|
||||
if (stored?.sourceCode === OWNER_WORKFLOW_SOURCE_CODE && stored.sourceUpdatedAt === change.completedAt) {
|
||||
|
||||
@ -80,6 +80,14 @@ export interface SupplyChainManagementOwnerConfigRepository {
|
||||
listOwnerHierarchy(): Promise<
|
||||
Array<{ operationOwner: string; departmentOwner: string; generalOwner: string }>
|
||||
>
|
||||
listOwnerEmployment(): Promise<
|
||||
Array<{
|
||||
name: string
|
||||
sourceStatus: 'EMPLOYED' | 'DIMISSIONED'
|
||||
departmentOwner: string
|
||||
generalOwner: string
|
||||
}>
|
||||
>
|
||||
listOwnerOptions(): Promise<
|
||||
Array<{
|
||||
operationOwner: string
|
||||
@ -172,11 +180,13 @@ class SupplyChainManagementOwnerConfigRepositoryClass
|
||||
}
|
||||
|
||||
async syncDirectoryConfigs(items: SupplyChainManagementOwnerDirectorySyncInput[]) {
|
||||
const activeItems = items.filter((item) => item.employmentStatus === '在职')
|
||||
const activeEmployeeIds = activeItems.map((item) => item.employeeId)
|
||||
const existingRecords = activeEmployeeIds.length > 0
|
||||
// 2026-10-12 口径:在职与已离职员工都同步进人员信息表;离职行保留部门并按当前在职
|
||||
// 角色刷新部门负责人/总负责人,供全量档案负责人在职状态识别与离职接管链匹配。
|
||||
// 人工维护(manualOverride)行照旧跳过自动字段覆盖,隐藏行照旧跳过。
|
||||
const syncedEmployeeIds = items.map((item) => item.employeeId)
|
||||
const existingRecords = syncedEmployeeIds.length > 0
|
||||
? await prisma.supplyChainManagementOwnerConfig.findMany({
|
||||
where: { sourceEmployeeId: { in: activeEmployeeIds } },
|
||||
where: { sourceEmployeeId: { in: syncedEmployeeIds } },
|
||||
select: { sourceEmployeeId: true, manualOverride: true, isHidden: true },
|
||||
})
|
||||
: []
|
||||
@ -187,8 +197,8 @@ class SupplyChainManagementOwnerConfigRepositoryClass
|
||||
record.sourceEmployeeId && record.manualOverride ? [record.sourceEmployeeId] : [],
|
||||
))
|
||||
const batchSize = 50
|
||||
for (let start = 0; start < activeItems.length; start += batchSize) {
|
||||
const batch = activeItems.slice(start, start + batchSize)
|
||||
for (let start = 0; start < items.length; start += batchSize) {
|
||||
const batch = items.slice(start, start + batchSize)
|
||||
.filter((item) => !existingByEmployeeId.get(item.employeeId)?.isHidden)
|
||||
await Promise.all(batch.map((item) =>
|
||||
prisma.supplyChainManagementOwnerConfig.upsert({
|
||||
@ -221,17 +231,18 @@ class SupplyChainManagementOwnerConfigRepositoryClass
|
||||
))
|
||||
}
|
||||
|
||||
// 清理只针对「未离职但已不在本轮同步名单」的行;已离职(DIMISSIONED)行永久保留。
|
||||
const removed = await prisma.supplyChainManagementOwnerConfig.deleteMany({
|
||||
where: {
|
||||
manualOverride: false,
|
||||
sourceEmployee: { is: { sourceStatus: { not: 'DIMISSIONED' } } },
|
||||
...(activeEmployeeIds.length > 0
|
||||
? { sourceEmployeeId: { notIn: activeEmployeeIds } }
|
||||
...(syncedEmployeeIds.length > 0
|
||||
? { sourceEmployeeId: { notIn: syncedEmployeeIds } }
|
||||
: { sourceEmployeeId: { not: null } }),
|
||||
},
|
||||
})
|
||||
return {
|
||||
synced: activeItems.length,
|
||||
synced: items.length,
|
||||
removed: removed.count,
|
||||
preserved: manuallyMaintainedEmployeeIds.size,
|
||||
}
|
||||
@ -298,6 +309,42 @@ class SupplyChainManagementOwnerConfigRepositoryClass
|
||||
return Array.from(byOwner.values())
|
||||
}
|
||||
|
||||
/** 全量档案负责人在职状态判定用:花名 → 在职状态 + 部门/总负责人(同名取最新一条)。 */
|
||||
async listOwnerEmployment() {
|
||||
const records = await prisma.supplyChainManagementOwnerConfig.findMany({
|
||||
where: { isHidden: false, operationOwner: { not: '' } },
|
||||
select: {
|
||||
operationOwner: true,
|
||||
departmentOwner: true,
|
||||
generalOwner: true,
|
||||
sourceEmployee: { select: { sourceStatus: true } },
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ operationOwner: 'asc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
const byOwner = new Map<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
sourceStatus: 'EMPLOYED' | 'DIMISSIONED'
|
||||
departmentOwner: string
|
||||
generalOwner: string
|
||||
}
|
||||
>()
|
||||
for (const record of records) {
|
||||
if (!byOwner.has(record.operationOwner)) {
|
||||
byOwner.set(record.operationOwner, {
|
||||
name: record.operationOwner,
|
||||
// 无员工目录关联的行(人工建)状态未知,按在职处理:不标注、不接管
|
||||
sourceStatus: record.sourceEmployee?.sourceStatus === 'DIMISSIONED' ? 'DIMISSIONED' : 'EMPLOYED',
|
||||
departmentOwner: record.departmentOwner ?? '',
|
||||
generalOwner: record.generalOwner ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byOwner.values())
|
||||
}
|
||||
|
||||
async listDirectoryContext() {
|
||||
const [employees, departments] = await Promise.all([
|
||||
prisma.employeeDirectory.findMany({
|
||||
|
||||
@ -69,6 +69,7 @@ const syncTargetSelect = {
|
||||
opsDeptHead: true,
|
||||
opsHead: true,
|
||||
inOutWarehouse: true,
|
||||
fieldSourceMap: true,
|
||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||
|
||||
export type ProductArchiveEnrichmentTarget =
|
||||
|
||||
@ -10,6 +10,12 @@ import {
|
||||
type ProductArchiveFilterOptionKey,
|
||||
type ProductArchiveTextFilterKey,
|
||||
} from '@/lib/supply-chain-product-archive'
|
||||
import {
|
||||
OWNER_EMPLOYMENT_STATUS_VALUES,
|
||||
buildOwnerEmploymentIndex,
|
||||
resolveOwnerTakeover,
|
||||
type OwnerEmploymentStatus,
|
||||
} from '@/lib/supply-chain-owner-employment'
|
||||
|
||||
function parseDateBoundary(value: string, endOfDay = false) {
|
||||
return new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}`)
|
||||
@ -67,6 +73,7 @@ export function buildProductArchiveWhere(
|
||||
{ spu: { contains: keyword, mode: 'insensitive' } },
|
||||
{ sku: { contains: keyword, mode: 'insensitive' } },
|
||||
{ productName: { contains: keyword, mode: 'insensitive' } },
|
||||
{ supplierName: { contains: keyword, mode: 'insensitive' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
@ -306,11 +313,77 @@ async function resolveNormalCodeClauses(
|
||||
return clauses
|
||||
}
|
||||
|
||||
/**
|
||||
* 「负责人在职状态」是虚拟筛选:在职状态不存在档案表上,而是把人员信息表的
|
||||
* 花名状态与档案 opsHead 的全部去重取值做交叉分类,再把命中的原始取值
|
||||
* (含零宽字符等变体)拼成 `in` 条件。人员状态与档案负责人取值都只在同步/
|
||||
* 导入时变化,按列缓存几分钟,避免每次翻页重复分类。
|
||||
*/
|
||||
const OWNER_EMPLOYMENT_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const ownerEmploymentCache = new Map<string, { values: string[]; status: OwnerEmploymentStatus[]; expiresAt: number }>()
|
||||
|
||||
async function listDistinctOpsHeadValues(): Promise<string[]> {
|
||||
const rows = await prisma.$queryRaw<Array<{ value: string | null }>>(Prisma.sql`
|
||||
SELECT DISTINCT COALESCE("opsHead", '') AS value
|
||||
FROM "SupplyChainProductArchive"
|
||||
`)
|
||||
return rows.map((row) => row.value ?? '')
|
||||
}
|
||||
|
||||
async function listOwnerEmploymentStatusClasses(): Promise<{ values: string[]; status: OwnerEmploymentStatus[] }> {
|
||||
const cached = ownerEmploymentCache.get('opsHead')
|
||||
if (cached && cached.expiresAt > Date.now()) return { values: cached.values, status: cached.status }
|
||||
|
||||
const [ownerRows, opsHeadValues] = await Promise.all([
|
||||
prisma.supplyChainManagementOwnerConfig.findMany({
|
||||
where: { isHidden: false, operationOwner: { not: '' } },
|
||||
select: {
|
||||
operationOwner: true,
|
||||
departmentOwner: true,
|
||||
generalOwner: true,
|
||||
sourceEmployee: { select: { sourceStatus: true } },
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: [{ operationOwner: 'asc' }, { updatedAt: 'desc' }],
|
||||
}),
|
||||
listDistinctOpsHeadValues(),
|
||||
])
|
||||
const index = buildOwnerEmploymentIndex(ownerRows.map((row) => ({
|
||||
name: row.operationOwner,
|
||||
sourceStatus: row.sourceEmployee?.sourceStatus === 'DIMISSIONED' ? 'DIMISSIONED' as const : 'EMPLOYED' as const,
|
||||
departmentOwner: row.departmentOwner ?? '',
|
||||
generalOwner: row.generalOwner ?? '',
|
||||
})))
|
||||
const status = opsHeadValues.map((value) => resolveOwnerTakeover(value, index).status)
|
||||
const snapshot = { values: opsHeadValues, status, expiresAt: Date.now() + OWNER_EMPLOYMENT_CACHE_TTL_MS }
|
||||
ownerEmploymentCache.set('opsHead', snapshot)
|
||||
return { values: opsHeadValues, status }
|
||||
}
|
||||
|
||||
/** 供负责人导入等会改写 opsHead 的路径清缓存用。 */
|
||||
export function clearOwnerEmploymentValueCache() {
|
||||
ownerEmploymentCache.delete('opsHead')
|
||||
}
|
||||
|
||||
async function resolveOwnerEmploymentClauses(
|
||||
filters: ProductArchiveFilters,
|
||||
): Promise<Prisma.SupplyChainProductArchiveWhereInput[]> {
|
||||
const selected = new Set((filters.ownerEmploymentStatus ?? []).filter((value) =>
|
||||
(OWNER_EMPLOYMENT_STATUS_VALUES as readonly string[]).includes(value),
|
||||
))
|
||||
if (selected.size === 0) return []
|
||||
|
||||
const { values, status } = await listOwnerEmploymentStatusClasses()
|
||||
const matchedRawValues = values.filter((_, index) => selected.has(status[index]))
|
||||
// 选中状态当前无人命中时给一个恒假条件,保持「筛选已生效但 0 行」的语义
|
||||
return [{ opsHead: { in: matchedRawValues } }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 带「文本规则」的取数条件。
|
||||
*
|
||||
* `buildProductArchiveWhere` 保持同步(只有纯 Prisma 条件,老调用点与老测试不受影响);
|
||||
* 需要查库的「只看正常编码」在这里补齐,再与基础条件合并。
|
||||
* 需要查库的「只看正常编码」「负责人在职状态」在这里补齐,再与基础条件合并。
|
||||
* 档案的列表、候选项、导出、图片导出都经由 preparePeriodArchiveQuery 走这一个入口。
|
||||
*/
|
||||
export async function buildProductArchiveWhereWithTextRules(
|
||||
@ -319,5 +392,7 @@ export async function buildProductArchiveWhereWithTextRules(
|
||||
): Promise<Prisma.SupplyChainProductArchiveWhereInput> {
|
||||
const base = buildProductArchiveWhere(filters, omitFilter)
|
||||
const extra = await resolveNormalCodeClauses(filters)
|
||||
return extra.length > 0 ? { AND: [base, ...extra] } : base
|
||||
const ownerClauses = await resolveOwnerEmploymentClauses(filters)
|
||||
const asyncClauses = [...extra, ...ownerClauses]
|
||||
return asyncClauses.length > 0 ? { AND: [base, ...asyncClauses] } : base
|
||||
}
|
||||
|
||||
@ -0,0 +1,228 @@
|
||||
import { OperationLogAction, OperationLogStatus, Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE } from '@/lib/supply-chain-product-archive-change-log'
|
||||
import {
|
||||
MANUAL_IMPORT_ARCHIVE_SOURCE_CODE,
|
||||
SUPPLY_CHAIN_OWNER_CONFIG_ARCHIVE_SOURCE_CODE,
|
||||
createProductArchiveFieldSourceMap,
|
||||
mergeProductArchiveFieldSourceMaps,
|
||||
parseProductArchiveFieldSourceMap,
|
||||
toProductArchiveFieldSourceJson,
|
||||
} from '@/lib/supply-chain-product-archive-field-source'
|
||||
import type { ProductArchiveChangeActor } from '@/server/services/supply-chain-product-archive-change-log.service'
|
||||
|
||||
export type ProductArchiveOwnerImportFieldChange = {
|
||||
field: 'opsHead' | 'designer' | 'opsDeptHead' | 'opsDirector'
|
||||
fieldLabel: string
|
||||
previousValue: string
|
||||
nextValue: string
|
||||
}
|
||||
|
||||
type ArchiveSnapshot = {
|
||||
id: string
|
||||
barcode: string
|
||||
opsHead: string
|
||||
opsDeptHead: string
|
||||
opsDirector: string
|
||||
designer: string
|
||||
fieldSourceMap: unknown
|
||||
}
|
||||
|
||||
const archiveSnapshotSelect = {
|
||||
id: true,
|
||||
barcode: true,
|
||||
opsHead: true,
|
||||
opsDeptHead: true,
|
||||
opsDirector: true,
|
||||
designer: true,
|
||||
fieldSourceMap: true,
|
||||
} satisfies Prisma.SupplyChainProductArchiveSelect
|
||||
|
||||
export interface ProductArchiveOwnerImportRepository {
|
||||
findByBarcode(barcode: string): Promise<ArchiveSnapshot | null>
|
||||
applyOwnerUpdate(input: {
|
||||
archiveId: string
|
||||
opsHead?: string
|
||||
designer?: string
|
||||
occurredAt: Date
|
||||
rowNumber: number
|
||||
actor: ProductArchiveChangeActor
|
||||
}): Promise<ProductArchiveOwnerImportFieldChange[]>
|
||||
applyHierarchyUpdate(input: {
|
||||
archiveId: string
|
||||
opsDeptHead?: string
|
||||
opsDirector?: string
|
||||
occurredAt: Date
|
||||
actor: ProductArchiveChangeActor
|
||||
}): Promise<ProductArchiveOwnerImportFieldChange[]>
|
||||
}
|
||||
|
||||
function fieldLabel(field: ProductArchiveOwnerImportFieldChange['field']) {
|
||||
if (field === 'opsHead') return '运营负责人'
|
||||
if (field === 'designer') return '设计师'
|
||||
if (field === 'opsDeptHead') return '运营部门负责人'
|
||||
return '运营总负责人'
|
||||
}
|
||||
|
||||
async function writeChangeLog(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
barcode: string
|
||||
actor: ProductArchiveChangeActor
|
||||
changes: readonly ProductArchiveOwnerImportFieldChange[]
|
||||
occurredAt: Date
|
||||
rowNumber?: number
|
||||
messageKind: '上传替换' | '上传后层级刷新'
|
||||
},
|
||||
) {
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
action: OperationLogAction.INTAKE_PRODUCT_WRITE,
|
||||
status: OperationLogStatus.SUCCESS,
|
||||
source: 'USER',
|
||||
actorId: input.actor.actorId ?? null,
|
||||
actorName: input.actor.actorName,
|
||||
skuCode: input.barcode,
|
||||
workspaceCategory: PRODUCT_ARCHIVE_CHANGE_LOG_SCOPE,
|
||||
message: `${input.messageKind}:${input.changes.map((change) => change.fieldLabel).join('、')}已更新`,
|
||||
details: {
|
||||
kind: 'UPDATED',
|
||||
barcode: input.barcode,
|
||||
field: input.changes.length === 1 ? input.changes[0].field : undefined,
|
||||
fieldLabel: input.changes.length === 1 ? input.changes[0].fieldLabel : undefined,
|
||||
fields: input.changes.map((change) => change.field),
|
||||
previousValues: Object.fromEntries(input.changes.map((change) => [change.field, change.previousValue])),
|
||||
nextValues: Object.fromEntries(input.changes.map((change) => [change.field, change.nextValue])),
|
||||
sourceSystem: '用户上传',
|
||||
sourceRecordedAt: input.occurredAt.toISOString(),
|
||||
...(input.rowNumber !== undefined ? { importRow: input.rowNumber } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export class ProductArchiveOwnerImportRepositoryClass implements ProductArchiveOwnerImportRepository {
|
||||
async findByBarcode(barcode: string) {
|
||||
return prisma.supplyChainProductArchive.findUnique({
|
||||
where: { barcode },
|
||||
select: archiveSnapshotSelect,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传替换运营负责人/设计师:值、字段来源(MANUAL_IMPORT + 上传时间戳,作为新的
|
||||
* 历史基准)与变更日志在同一事务内落库。值未变化时不动库、不写日志。
|
||||
*/
|
||||
async applyOwnerUpdate(input: {
|
||||
archiveId: string
|
||||
opsHead?: string
|
||||
designer?: string
|
||||
occurredAt: Date
|
||||
rowNumber: number
|
||||
actor: ProductArchiveChangeActor
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.supplyChainProductArchive.findUniqueOrThrow({
|
||||
where: { id: input.archiveId },
|
||||
select: archiveSnapshotSelect,
|
||||
})
|
||||
const changes: ProductArchiveOwnerImportFieldChange[] = []
|
||||
if (input.opsHead !== undefined && input.opsHead !== previous.opsHead) {
|
||||
changes.push({ field: 'opsHead', fieldLabel: fieldLabel('opsHead'), previousValue: previous.opsHead, nextValue: input.opsHead })
|
||||
}
|
||||
if (input.designer !== undefined && input.designer !== previous.designer) {
|
||||
changes.push({ field: 'designer', fieldLabel: fieldLabel('designer'), previousValue: previous.designer, nextValue: input.designer })
|
||||
}
|
||||
if (changes.length === 0) return []
|
||||
|
||||
const sourceColumns: Record<string, string> = {}
|
||||
if (changes.some((change) => change.field === 'opsHead')) sourceColumns.opsHead = '运营负责人'
|
||||
if (changes.some((change) => change.field === 'designer')) sourceColumns.designer = '设计师'
|
||||
const incoming = createProductArchiveFieldSourceMap({
|
||||
fields: sourceColumns,
|
||||
sourceCode: MANUAL_IMPORT_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: '用户上传',
|
||||
sourceSchema: 'product-archive-owner-import',
|
||||
sourceObject: 'OwnerImport',
|
||||
sourceRecordId: `${previous.barcode}:${input.occurredAt.toISOString()}`,
|
||||
sourceUpdatedAt: input.occurredAt,
|
||||
})
|
||||
await tx.supplyChainProductArchive.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
...(changes.some((change) => change.field === 'opsHead') ? { opsHead: input.opsHead } : {}),
|
||||
...(changes.some((change) => change.field === 'designer') ? { designer: input.designer } : {}),
|
||||
fieldSourceMap: toProductArchiveFieldSourceJson(
|
||||
mergeProductArchiveFieldSourceMaps(parseProductArchiveFieldSourceMap(previous.fieldSourceMap), incoming),
|
||||
),
|
||||
},
|
||||
})
|
||||
await writeChangeLog(tx, {
|
||||
barcode: previous.barcode,
|
||||
actor: input.actor,
|
||||
changes,
|
||||
occurredAt: input.occurredAt,
|
||||
rowNumber: input.rowNumber,
|
||||
messageKind: '上传替换',
|
||||
})
|
||||
return changes
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传成功后立即按人员信息层级刷新部门负责人/总负责人(与每日 enrichment 同口径)。 */
|
||||
async applyHierarchyUpdate(input: {
|
||||
archiveId: string
|
||||
opsDeptHead?: string
|
||||
opsDirector?: string
|
||||
occurredAt: Date
|
||||
actor: ProductArchiveChangeActor
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.supplyChainProductArchive.findUniqueOrThrow({
|
||||
where: { id: input.archiveId },
|
||||
select: archiveSnapshotSelect,
|
||||
})
|
||||
const changes: ProductArchiveOwnerImportFieldChange[] = []
|
||||
if (input.opsDeptHead !== undefined && input.opsDeptHead !== previous.opsDeptHead) {
|
||||
changes.push({ field: 'opsDeptHead', fieldLabel: fieldLabel('opsDeptHead'), previousValue: previous.opsDeptHead, nextValue: input.opsDeptHead })
|
||||
}
|
||||
if (input.opsDirector !== undefined && input.opsDirector !== previous.opsDirector) {
|
||||
changes.push({ field: 'opsDirector', fieldLabel: fieldLabel('opsDirector'), previousValue: previous.opsDirector, nextValue: input.opsDirector })
|
||||
}
|
||||
if (changes.length === 0) return []
|
||||
|
||||
const sourceColumns: Record<string, string> = {}
|
||||
if (changes.some((change) => change.field === 'opsDeptHead')) sourceColumns.opsDeptHead = 'departmentOwner'
|
||||
if (changes.some((change) => change.field === 'opsDirector')) sourceColumns.opsDirector = 'generalOwner'
|
||||
const incoming = createProductArchiveFieldSourceMap({
|
||||
fields: sourceColumns,
|
||||
sourceCode: SUPPLY_CHAIN_OWNER_CONFIG_ARCHIVE_SOURCE_CODE,
|
||||
sourceSystem: 'SupplyChainManagementOwnerConfig',
|
||||
sourceSchema: 'public',
|
||||
sourceObject: 'SupplyChainManagementOwnerConfig',
|
||||
sourceRecordId: previous.barcode,
|
||||
sourceUpdatedAt: input.occurredAt,
|
||||
})
|
||||
await tx.supplyChainProductArchive.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
...(changes.some((change) => change.field === 'opsDeptHead') ? { opsDeptHead: input.opsDeptHead } : {}),
|
||||
...(changes.some((change) => change.field === 'opsDirector') ? { opsDirector: input.opsDirector } : {}),
|
||||
fieldSourceMap: toProductArchiveFieldSourceJson(
|
||||
mergeProductArchiveFieldSourceMaps(parseProductArchiveFieldSourceMap(previous.fieldSourceMap), incoming),
|
||||
),
|
||||
},
|
||||
})
|
||||
await writeChangeLog(tx, {
|
||||
barcode: previous.barcode,
|
||||
actor: input.actor,
|
||||
changes,
|
||||
occurredAt: input.occurredAt,
|
||||
messageKind: '上传后层级刷新',
|
||||
})
|
||||
return changes
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const productArchiveOwnerImportRepository = new ProductArchiveOwnerImportRepositoryClass()
|
||||
@ -68,6 +68,50 @@ describe('decideProductArchiveDesignerFill', () => {
|
||||
}),
|
||||
).toEqual({ kind: 'conflict-kept' })
|
||||
})
|
||||
|
||||
it('skips rows whose designer came from a newer manual import', () => {
|
||||
expect(
|
||||
decideProductArchiveDesignerFill({
|
||||
currentDesigner: '上传设计师',
|
||||
sourceDesigner: '聚水潭设计师',
|
||||
conflictDesigner: '辛环',
|
||||
storedDesignerSource: {
|
||||
sourceCode: 'MANUAL_IMPORT',
|
||||
sourceUpdatedAt: '2026-10-11T00:00:00.000Z',
|
||||
},
|
||||
sourceRecordedAt: '2026-10-09T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({ kind: 'skip' })
|
||||
})
|
||||
|
||||
it('lets an updated source row win over an older manual import', () => {
|
||||
expect(
|
||||
decideProductArchiveDesignerFill({
|
||||
currentDesigner: '上传设计师',
|
||||
sourceDesigner: '聚水潭设计师',
|
||||
conflictDesigner: '辛环',
|
||||
storedDesignerSource: {
|
||||
sourceCode: 'MANUAL_IMPORT',
|
||||
sourceUpdatedAt: '2026-10-08T00:00:00.000Z',
|
||||
},
|
||||
sourceRecordedAt: '2026-10-11T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({ kind: 'override', designer: '辛环' })
|
||||
})
|
||||
|
||||
it('applies normal rules when the stored source is not a manual import', () => {
|
||||
expect(
|
||||
decideProductArchiveDesignerFill({
|
||||
currentDesigner: '',
|
||||
sourceDesigner: '灵汐',
|
||||
storedDesignerSource: {
|
||||
sourceCode: 'INTAKE_NON_INTEGRATED_PROFILE',
|
||||
sourceUpdatedAt: '2026-10-11T00:00:00.000Z',
|
||||
},
|
||||
sourceRecordedAt: '2026-10-09T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({ kind: 'fill', designer: '灵汐' })
|
||||
})
|
||||
})
|
||||
|
||||
function createDependencies(input: {
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
|
||||
jest.mock('@/server/repositories/supply-chain-product-archive-filters', () => ({
|
||||
clearOwnerEmploymentValueCache: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/repositories/supply-chain-management-owner-config.repository', () => ({
|
||||
supplyChainManagementOwnerConfigRepository: {},
|
||||
}))
|
||||
|
||||
import { clearOwnerEmploymentValueCache } from '@/server/repositories/supply-chain-product-archive-filters'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { ProductArchiveOwnerImportRow } from '@/lib/supply-chain-product-archive-owner-import'
|
||||
import {
|
||||
SupplyChainProductArchiveOwnerImportService,
|
||||
} from '../supply-chain-product-archive-owner-import.service'
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
name: '测试用户',
|
||||
} as unknown as AuthUser
|
||||
|
||||
function setup(input: {
|
||||
ownerEntries?: Array<{ name: string; sourceStatus?: 'EMPLOYED' | 'DIMISSIONED'; departmentOwner?: string; generalOwner?: string }>
|
||||
hierarchy?: Array<{ operationOwner: string; departmentOwner: string; generalOwner: string }>
|
||||
archives?: Record<string, { id: string; opsHead: string; designer: string }>
|
||||
}) {
|
||||
const ownerEntries = (input.ownerEntries ?? [{ name: '半夏' }, { name: '五竹', sourceStatus: 'DIMISSIONED' }])
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
sourceStatus: entry.sourceStatus ?? 'EMPLOYED' as const,
|
||||
departmentOwner: entry.departmentOwner ?? '',
|
||||
generalOwner: entry.generalOwner ?? '',
|
||||
}))
|
||||
const hierarchy = input.hierarchy ?? [{ operationOwner: '半夏', departmentOwner: '半夏', generalOwner: '江陵' }]
|
||||
const applied: Array<{ archiveId: string; opsHead?: string; designer?: string }> = []
|
||||
const hierarchyApplied: Array<{ archiveId: string; opsDeptHead?: string; opsDirector?: string }> = []
|
||||
const repository = {
|
||||
findByBarcode: jest.fn(async (barcode: string) => {
|
||||
const archive = (input.archives ?? {})[barcode]
|
||||
return archive ? { id: archive.id, barcode, opsHead: archive.opsHead, opsDeptHead: '', opsDirector: '', designer: archive.designer, fieldSourceMap: {} } : null
|
||||
}),
|
||||
applyOwnerUpdate: jest.fn(async ({ archiveId, opsHead, designer }: { archiveId: string; opsHead?: string; designer?: string }) => {
|
||||
const current = Object.values(input.archives ?? {}).find((archive) => archive.id === archiveId)
|
||||
const changes = []
|
||||
if (opsHead !== undefined && opsHead !== current?.opsHead) changes.push({ field: 'opsHead' })
|
||||
if (designer !== undefined && designer !== current?.designer) changes.push({ field: 'designer' })
|
||||
if (changes.length) applied.push({ archiveId, opsHead, designer })
|
||||
return changes.map((change) => ({ field: change.field, fieldLabel: change.field, previousValue: '', nextValue: '' }))
|
||||
}),
|
||||
applyHierarchyUpdate: jest.fn(async (payload: { archiveId: string; opsDeptHead?: string; opsDirector?: string }) => {
|
||||
hierarchyApplied.push(payload)
|
||||
return []
|
||||
}),
|
||||
}
|
||||
const ownerConfigRepository = {
|
||||
listOwnerEmployment: jest.fn().mockResolvedValue(ownerEntries),
|
||||
listOwnerHierarchy: jest.fn().mockResolvedValue(hierarchy),
|
||||
}
|
||||
const service = new SupplyChainProductArchiveOwnerImportService(
|
||||
repository as never,
|
||||
ownerConfigRepository as never,
|
||||
)
|
||||
return { service, repository, ownerConfigRepository, applied, hierarchyApplied }
|
||||
}
|
||||
|
||||
const row = (partial: Partial<ProductArchiveOwnerImportRow> & { barcode: string }): ProductArchiveOwnerImportRow => ({
|
||||
rowNumber: partial.rowNumber ?? 2,
|
||||
barcode: partial.barcode,
|
||||
opsHead: partial.opsHead ?? '',
|
||||
designer: partial.designer ?? '',
|
||||
})
|
||||
|
||||
describe('SupplyChainProductArchiveOwnerImportService', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it('替换运营负责人并按人员信息刷新层级', async () => {
|
||||
const { service, applied, hierarchyApplied } = setup({
|
||||
archives: { 'BAR-1': { id: 'a1', opsHead: '旧负责人', designer: '' } },
|
||||
})
|
||||
const result = await service.importRows([row({ barcode: 'BAR-1', opsHead: '半夏' })], user)
|
||||
expect(result).toMatchObject({ processed: 1, updated: 1, skipped: 0, failed: 0 })
|
||||
expect(applied).toEqual([{ archiveId: 'a1', opsHead: '半夏', designer: undefined }])
|
||||
expect(hierarchyApplied).toEqual([
|
||||
expect.objectContaining({ archiveId: 'a1', opsDeptHead: '半夏', opsDirector: '江陵' }),
|
||||
])
|
||||
expect(clearOwnerEmploymentValueCache).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('设计师自由文本,负责人严格校验人员信息', async () => {
|
||||
const { service } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '', designer: '' } } })
|
||||
const result = await service.importRows([
|
||||
row({ barcode: 'BAR-1', designer: '外部设计师' }),
|
||||
row({ rowNumber: 3, barcode: 'BAR-1', opsHead: '不存在的人' }),
|
||||
], user)
|
||||
expect(result.updated).toBe(1)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.errors[0]).toMatchObject({ rowNumber: 3, barcode: 'BAR-1' })
|
||||
expect(result.errors[0].message).toContain('不在人员信息中')
|
||||
})
|
||||
|
||||
it('上传已离职负责人:该行成功并给出接管提示', async () => {
|
||||
const { service } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '', designer: '' } } })
|
||||
const result = await service.importRows([row({ barcode: 'BAR-1', opsHead: '五竹' })], user)
|
||||
expect(result.updated).toBe(1)
|
||||
expect(result.warnings[0].message).toContain('已离职')
|
||||
})
|
||||
|
||||
it('条码不存在与两列全空的行分别计失败与跳过', async () => {
|
||||
const { service } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '', designer: '' } } })
|
||||
const result = await service.importRows([
|
||||
row({ barcode: 'BAR-X', opsHead: '半夏' }),
|
||||
row({ barcode: 'BAR-1' }),
|
||||
row({ rowNumber: 4, barcode: '' , opsHead: '半夏' }),
|
||||
], user)
|
||||
expect(result).toMatchObject({ processed: 3, updated: 0, skipped: 1, failed: 2 })
|
||||
expect(result.errors.map((error) => error.message)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('不存在于产品档案'), '条码为空']),
|
||||
)
|
||||
})
|
||||
|
||||
it('值与现值相同的行计跳过;层级刷新失败不影响导入结果', async () => {
|
||||
const { service, repository } = setup({ archives: { 'BAR-1': { id: 'a1', opsHead: '半夏', designer: '' } } })
|
||||
repository.applyHierarchyUpdate.mockRejectedValue(new Error('hierarchy down'))
|
||||
const result = await service.importRows([row({ barcode: 'BAR-1', opsHead: '半夏' })], user)
|
||||
expect(result).toMatchObject({ updated: 0, skipped: 1, failed: 0 })
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import { JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE } from '@/lib/supply-chain-product-archive-field-source'
|
||||
import { JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE, MANUAL_IMPORT_ARCHIVE_SOURCE_CODE, parseProductArchiveFieldSourceMap } from '@/lib/supply-chain-product-archive-field-source'
|
||||
import {
|
||||
supplyChainProductArchiveEnrichmentSyncRepository,
|
||||
type ProductArchiveEnrichmentPatch,
|
||||
@ -30,10 +30,31 @@ export function decideProductArchiveDesignerFill(input: {
|
||||
currentDesigner: string | null | undefined
|
||||
sourceDesigner: string | null | undefined
|
||||
conflictDesigner?: string | null
|
||||
/** 档案当前设计师的来源信息(fieldSourceMap.designer),用于识别上传基准 */
|
||||
storedDesignerSource?: { sourceCode: string; sourceUpdatedAt: string } | null
|
||||
/** 聚水潭源行的更新时间;与上传时间戳比较决定谁新 */
|
||||
sourceRecordedAt?: Date | string | null
|
||||
}): ProductArchiveDesignerFillDecision {
|
||||
const sourceDesigner = input.sourceDesigner?.trim() ?? ''
|
||||
if (!sourceDesigner) return { kind: 'skip' }
|
||||
|
||||
// 上传替换(MANUAL_IMPORT)是新的历史基准:聚水潭行更新时间不晚于上传时间时
|
||||
// 不动档案设计师;聚水潭行后来又更新过才按原规则参与填空/冲突处理。
|
||||
if (
|
||||
input.storedDesignerSource?.sourceCode === MANUAL_IMPORT_ARCHIVE_SOURCE_CODE
|
||||
&& input.sourceRecordedAt
|
||||
) {
|
||||
const uploadedAt = Date.parse(input.storedDesignerSource.sourceUpdatedAt)
|
||||
const recordedAt = Date.parse(
|
||||
input.sourceRecordedAt instanceof Date
|
||||
? input.sourceRecordedAt.toISOString()
|
||||
: String(input.sourceRecordedAt),
|
||||
)
|
||||
if (Number.isFinite(uploadedAt) && Number.isFinite(recordedAt) && uploadedAt >= recordedAt) {
|
||||
return { kind: 'skip' }
|
||||
}
|
||||
}
|
||||
|
||||
const currentDesigner = input.currentDesigner?.trim() ?? ''
|
||||
if (!currentDesigner) return { kind: 'fill', designer: sourceDesigner }
|
||||
if (currentDesigner === sourceDesigner) return { kind: 'unchanged' }
|
||||
@ -128,6 +149,8 @@ export class SupplyChainProductArchiveDesignerFillService {
|
||||
currentDesigner: target.designer,
|
||||
sourceDesigner: profile.designer,
|
||||
conflictDesigner,
|
||||
storedDesignerSource: parseProductArchiveFieldSourceMap(target.fieldSourceMap).designer,
|
||||
sourceRecordedAt: profile.updatedAt,
|
||||
})
|
||||
if (decision.kind === 'skip') continue
|
||||
if (decision.kind === 'unchanged') {
|
||||
|
||||
@ -14,6 +14,16 @@ import {
|
||||
supplyChainProductArchiveWarehouseConfigRepository,
|
||||
type ProductArchiveWarehouseConfigRecord,
|
||||
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
|
||||
import {
|
||||
supplyChainManagementOwnerConfigRepository,
|
||||
type SupplyChainManagementOwnerConfigRepository,
|
||||
} from '@/server/repositories/supply-chain-management-owner-config.repository'
|
||||
import {
|
||||
buildOwnerEmploymentIndex,
|
||||
resolveDesignerView,
|
||||
resolveOwnerTakeover,
|
||||
type OwnerEmploymentIndex,
|
||||
} from '@/lib/supply-chain-owner-employment'
|
||||
import {
|
||||
resolveProductArchiveWarehouseFields,
|
||||
} from './supply-chain-product-archive.service'
|
||||
@ -128,6 +138,10 @@ export class SupplyChainProductArchiveExportService {
|
||||
'listActive'
|
||||
> = supplyChainProductArchiveWarehouseConfigRepository,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
private readonly ownerEmploymentRepository: Pick<
|
||||
SupplyChainManagementOwnerConfigRepository,
|
||||
'listOwnerEmployment'
|
||||
> | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -135,6 +149,8 @@ export class SupplyChainProductArchiveExportService {
|
||||
*
|
||||
* 仓库配置只在开头取一次(和旧实现一致),不能每行去查;映射器本身无状态,
|
||||
* 可以跨批复用。`now` 也在这里固定,保证同一份导出里所有相对时间字段口径一致。
|
||||
* 人员信息在职状态同样只取一次:导出里运营负责人/设计师列直接写「接管展示值」,
|
||||
* 与页面同一口径(2026-10-12)。
|
||||
*/
|
||||
private async createRowMapper() {
|
||||
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
|
||||
@ -143,6 +159,16 @@ export class SupplyChainProductArchiveExportService {
|
||||
} catch (error) {
|
||||
console.warn('[product-archive-export] warehouse config lookup failed', error)
|
||||
}
|
||||
let ownerEmploymentIndex: OwnerEmploymentIndex | null = null
|
||||
if (this.ownerEmploymentRepository) {
|
||||
try {
|
||||
ownerEmploymentIndex = buildOwnerEmploymentIndex(
|
||||
await this.ownerEmploymentRepository.listOwnerEmployment(),
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[product-archive-export] owner employment lookup failed', error)
|
||||
}
|
||||
}
|
||||
const warehouseConfigByName = new Map(
|
||||
warehouseConfigs.map((config) => [normalizeWarehouseName(config.warehouseName), config]),
|
||||
)
|
||||
@ -157,9 +183,17 @@ export class SupplyChainProductArchiveExportService {
|
||||
currentStorageType: row.storageType,
|
||||
config,
|
||||
})
|
||||
if (!ownerEmploymentIndex) {
|
||||
return {
|
||||
...row,
|
||||
...warehouseFields,
|
||||
} satisfies SupplyChainProductArchiveRow
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
...warehouseFields,
|
||||
opsHead: resolveOwnerTakeover(record.opsHead ?? '', ownerEmploymentIndex).display || row.opsHead,
|
||||
designer: resolveDesignerView(record.designer ?? '', ownerEmploymentIndex).display || row.designer,
|
||||
} satisfies SupplyChainProductArchiveRow
|
||||
}
|
||||
}
|
||||
@ -250,4 +284,9 @@ export class SupplyChainProductArchiveExportService {
|
||||
}
|
||||
|
||||
export const supplyChainProductArchiveExportService =
|
||||
new SupplyChainProductArchiveExportService()
|
||||
new SupplyChainProductArchiveExportService(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
supplyChainManagementOwnerConfigRepository,
|
||||
)
|
||||
|
||||
@ -0,0 +1,153 @@
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import {
|
||||
normalizeOwnerName,
|
||||
type OwnerEmploymentEntry,
|
||||
} from '@/lib/supply-chain-owner-employment'
|
||||
import type { ProductArchiveOwnerImportProgress, ProductArchiveOwnerImportRow } from '@/lib/supply-chain-product-archive-owner-import'
|
||||
import {
|
||||
productArchiveOwnerImportRepository,
|
||||
type ProductArchiveOwnerImportRepository,
|
||||
} from '@/server/repositories/supply-chain-product-archive-owner-import.repository'
|
||||
import {
|
||||
supplyChainManagementOwnerConfigRepository,
|
||||
type SupplyChainManagementOwnerConfigRepository,
|
||||
} from '@/server/repositories/supply-chain-management-owner-config.repository'
|
||||
import { clearOwnerEmploymentValueCache } from '@/server/repositories/supply-chain-product-archive-filters'
|
||||
import { archiveChangeActorFromUser, type ProductArchiveChangeActor } from './supply-chain-product-archive-change-log.service'
|
||||
|
||||
export class SupplyChainProductArchiveOwnerImportService {
|
||||
constructor(
|
||||
private readonly repository: ProductArchiveOwnerImportRepository =
|
||||
productArchiveOwnerImportRepository,
|
||||
private readonly ownerConfigRepository: Pick<
|
||||
SupplyChainManagementOwnerConfigRepository,
|
||||
'listOwnerEmployment' | 'listOwnerHierarchy'
|
||||
> = supplyChainManagementOwnerConfigRepository,
|
||||
) {}
|
||||
|
||||
async importRows(
|
||||
rows: ProductArchiveOwnerImportRow[],
|
||||
user: AuthUser,
|
||||
onProgress?: (progress: ProductArchiveOwnerImportProgress) => void,
|
||||
): Promise<ProductArchiveOwnerImportProgress> {
|
||||
const progress: ProductArchiveOwnerImportProgress = {
|
||||
processed: 0,
|
||||
total: rows.length,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
}
|
||||
const ownerEntries = await this.loadOwnerEntries()
|
||||
const ownerByName = new Map(ownerEntries.map((entry) => [normalizeOwnerName(entry.name), entry]))
|
||||
const actor = archiveChangeActorFromUser(user)
|
||||
const occurredAt = new Date()
|
||||
const hierarchyRefreshBarcodes: Array<{ archiveId: string; opsHead: string }> = []
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const barcode = row.barcode.trim()
|
||||
const opsHead = row.opsHead.trim()
|
||||
const designer = row.designer.trim()
|
||||
if (!barcode) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '条码为空')
|
||||
}
|
||||
if (!opsHead && !designer) {
|
||||
// 整行两列都空:不算失败,按跳过计(空单元格本来也不覆盖)
|
||||
progress.skipped += 1
|
||||
} else {
|
||||
if (opsHead) {
|
||||
const entry = ownerByName.get(normalizeOwnerName(opsHead))
|
||||
if (!entry) {
|
||||
throw new AppError(
|
||||
ErrorCode.SUPPLY_CHAIN_OWNER_CONFIG_NOT_FOUND,
|
||||
`运营负责人「${opsHead}」不在人员信息中,请先在人员信息补录该人员`,
|
||||
)
|
||||
}
|
||||
if (entry.sourceStatus === 'DIMISSIONED') {
|
||||
progress.warnings.push({
|
||||
rowNumber: row.rowNumber,
|
||||
barcode,
|
||||
message: `运营负责人「${opsHead}」已离职,页面将按接管链展示`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const archive = await this.repository.findByBarcode(barcode)
|
||||
if (!archive) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, `条码 ${barcode} 不存在于产品档案`)
|
||||
}
|
||||
const changes = await this.repository.applyOwnerUpdate({
|
||||
archiveId: archive.id,
|
||||
...(opsHead ? { opsHead } : {}),
|
||||
...(designer ? { designer } : {}),
|
||||
occurredAt,
|
||||
rowNumber: row.rowNumber,
|
||||
actor,
|
||||
})
|
||||
if (changes.length === 0) {
|
||||
progress.skipped += 1
|
||||
} else {
|
||||
progress.updated += 1
|
||||
if (opsHead) hierarchyRefreshBarcodes.push({ archiveId: archive.id, opsHead })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
progress.failed += 1
|
||||
progress.errors.push({
|
||||
rowNumber: row.rowNumber,
|
||||
barcode: row.barcode,
|
||||
message: error instanceof Error ? error.message : '导入失败',
|
||||
})
|
||||
}
|
||||
progress.processed += 1
|
||||
onProgress?.({ ...progress, warnings: [...progress.warnings], errors: [...progress.errors] })
|
||||
}
|
||||
|
||||
// 上传改了负责人:立即按人员信息刷新该批条码的部门负责人/总负责人,
|
||||
// 并清掉在职状态筛选缓存,页面筛选立刻感知新负责人,不等第二天同步。
|
||||
if (hierarchyRefreshBarcodes.length > 0) {
|
||||
await this.refreshHierarchy(hierarchyRefreshBarcodes, actor, occurredAt)
|
||||
}
|
||||
clearOwnerEmploymentValueCache()
|
||||
return progress
|
||||
}
|
||||
|
||||
private async loadOwnerEntries(): Promise<OwnerEmploymentEntry[]> {
|
||||
try {
|
||||
return await this.ownerConfigRepository.listOwnerEmployment()
|
||||
} catch (error) {
|
||||
console.warn('[product-archive-owner-import] owner config lookup failed', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshHierarchy(
|
||||
targets: Array<{ archiveId: string; opsHead: string }>,
|
||||
actor: ProductArchiveChangeActor,
|
||||
occurredAt: Date,
|
||||
) {
|
||||
try {
|
||||
const hierarchy = await this.ownerConfigRepository.listOwnerHierarchy()
|
||||
const hierarchyByPerson = new Map(hierarchy.map((entry) => [normalizeOwnerName(entry.operationOwner), entry]))
|
||||
for (const target of targets) {
|
||||
const entry = hierarchyByPerson.get(normalizeOwnerName(target.opsHead))
|
||||
if (!entry || (!entry.departmentOwner && !entry.generalOwner)) continue
|
||||
await this.repository.applyHierarchyUpdate({
|
||||
archiveId: target.archiveId,
|
||||
...(entry.departmentOwner ? { opsDeptHead: entry.departmentOwner } : {}),
|
||||
...(entry.generalOwner ? { opsDirector: entry.generalOwner } : {}),
|
||||
occurredAt,
|
||||
actor,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 层级刷新失败不回滚已成功的负责人替换;每日 enrichment 会自然补齐
|
||||
console.warn('[product-archive-owner-import] hierarchy refresh failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supplyChainProductArchiveOwnerImportService =
|
||||
new SupplyChainProductArchiveOwnerImportService()
|
||||
@ -6,6 +6,7 @@ import {
|
||||
PRODUCT_ARCHIVE_TEXT_FILTER_KEYS,
|
||||
type ProductArchiveFilters,
|
||||
} from '@/lib/supply-chain-product-archive'
|
||||
import { OWNER_EMPLOYMENT_STATUS_VALUES } from '@/lib/supply-chain-owner-employment'
|
||||
|
||||
const archiveDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
const archiveSalesGranularitySchema = z.enum(['day', 'month', 'year'])
|
||||
@ -70,6 +71,8 @@ const productArchiveFiltersSchema = z.object({
|
||||
opsDirector: archiveValueArraySchema.optional(),
|
||||
opsDeptHead: archiveValueArraySchema.optional(),
|
||||
opsHead: archiveValueArraySchema.optional(),
|
||||
ownerEmploymentStatus: z.array(z.enum(OWNER_EMPLOYMENT_STATUS_VALUES))
|
||||
.max(OWNER_EMPLOYMENT_STATUS_VALUES.length).optional(),
|
||||
hasDrawing: archiveValueArraySchema.optional(),
|
||||
hasVideo: archiveValueArraySchema.optional(),
|
||||
warehouseHead: archiveValueArraySchema.optional(),
|
||||
|
||||
@ -21,6 +21,13 @@ import {
|
||||
supplyChainProductArchiveWarehouseConfigRepository,
|
||||
type ProductArchiveWarehouseConfigRecord,
|
||||
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
|
||||
import {
|
||||
buildOwnerEmploymentIndex,
|
||||
resolveDesignerView,
|
||||
resolveOwnerTakeover,
|
||||
} from '@/lib/supply-chain-owner-employment'
|
||||
import type { SupplyChainManagementOwnerConfigRepository } from '@/server/repositories/supply-chain-management-owner-config.repository'
|
||||
import { supplyChainManagementOwnerConfigRepository } from '@/server/repositories/supply-chain-management-owner-config.repository'
|
||||
|
||||
export function resolveProductArchiveWarehouseFields(input: {
|
||||
warehouseName: string
|
||||
@ -48,8 +55,23 @@ export class SupplyChainProductArchiveService {
|
||||
typeof supplyChainProductArchiveWarehouseConfigRepository,
|
||||
'listActive'
|
||||
> | null = null,
|
||||
private readonly ownerEmploymentRepository: Pick<
|
||||
SupplyChainManagementOwnerConfigRepository,
|
||||
'listOwnerEmployment'
|
||||
> | null = null,
|
||||
) {}
|
||||
|
||||
/** 人员信息在职状态索引;加载失败时返回 null,页面回退为不标注的原样展示。 */
|
||||
private async loadOwnerEmploymentIndex() {
|
||||
if (!this.ownerEmploymentRepository) return null
|
||||
try {
|
||||
return buildOwnerEmploymentIndex(await this.ownerEmploymentRepository.listOwnerEmployment())
|
||||
} catch (error) {
|
||||
console.warn('[product-archive] owner employment lookup failed', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async listPage(params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
@ -73,6 +95,7 @@ export class SupplyChainProductArchiveService {
|
||||
const warehouseConfigByName = new Map(
|
||||
warehouseConfigs.map((config) => [normalizeWarehouseName(config.warehouseName), config]),
|
||||
)
|
||||
const ownerEmploymentIndex = await this.loadOwnerEmploymentIndex()
|
||||
const now = new Date()
|
||||
return {
|
||||
salesPeriod,
|
||||
@ -85,9 +108,18 @@ export class SupplyChainProductArchiveService {
|
||||
currentStorageType: row.storageType,
|
||||
config,
|
||||
})
|
||||
// 负责人/设计师在职状态标注:索引不可用时缺省,前端回退展示原值
|
||||
if (!ownerEmploymentIndex) {
|
||||
return { ...row, ...warehouseFields }
|
||||
}
|
||||
const ownerView = resolveOwnerTakeover(record.opsHead ?? '', ownerEmploymentIndex)
|
||||
const designerView = resolveDesignerView(row.designer ?? '', ownerEmploymentIndex)
|
||||
return {
|
||||
...row,
|
||||
...warehouseFields,
|
||||
opsHeadDisplay: ownerView.display || row.opsHead,
|
||||
ownerEmploymentStatus: ownerView.status,
|
||||
designerDisplay: designerView.display || row.designer,
|
||||
}
|
||||
}),
|
||||
pageInfo: {
|
||||
@ -122,4 +154,5 @@ export const supplyChainProductArchiveService =
|
||||
new SupplyChainProductArchiveService(
|
||||
undefined,
|
||||
supplyChainProductArchiveWarehouseConfigRepository,
|
||||
supplyChainManagementOwnerConfigRepository,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user