feat(progress): add column header filters

This commit is contained in:
WuYongJie 2026-09-15 16:57:42 +08:00
parent 53febe8eb7
commit 260fbc8428
16 changed files with 999 additions and 16 deletions

View File

@ -0,0 +1,94 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
const mockHandleRequest = jest.fn()
const mockListColumnFilterOptions = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (data: unknown, status = 200, requestId?: string) =>
new Response(JSON.stringify({ success: true, data, meta: { requestId } }), {
status,
headers: { 'content-type': 'application/json' },
}),
jsonError: (code: string, message?: string, status = 400) =>
new Response(JSON.stringify({ success: false, error: { code, message } }), {
status,
headers: { 'content-type': 'application/json' },
}),
}))
jest.mock('@/server/services/finished-product-progress.service', () => ({
finishedProductProgressService: {
listColumnFilterOptions: (...args: unknown[]) => mockListColumnFilterOptions(...args),
},
}))
const { POST } = require('./route')
function post(body: unknown) {
return POST({
json: async () => body,
headers: { get: () => String(JSON.stringify(body).length) },
} as any)
}
describe('POST /api/supply-chain/finished-product-progress/filter-options', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
handler({ requestId: 'req-filter-options', user: { id: 'user-1', role: 'USER', status: 'ACTIVE' } }),
)
mockListColumnFilterOptions.mockResolvedValue({ values: ['供应商甲', '供应商乙', ''] })
})
it('按字段与当前筛选条件调用 service 计算候选值', async () => {
const response = await post({
field: 'followUpOwner',
search: '订单',
supplier: ['供应商甲'],
brand: [],
currentNode: [],
dueDateFrom: '2026-09-01',
dueDateTo: '',
columnFilters: { brand: { mode: 'include', values: ['品牌A'] } },
})
const payload = await response.json()
expect(mockListColumnFilterOptions).toHaveBeenCalledWith({
field: 'followUpOwner',
filters: {
search: '订单',
supplier: ['供应商甲'],
brand: [],
currentNode: [],
dueDateFrom: '2026-09-01',
dueDateTo: '',
columnFilters: { brand: { mode: 'include', values: ['品牌A'] } },
},
user: expect.objectContaining({ id: 'user-1' }),
})
expect(payload).toEqual(expect.objectContaining({
success: true,
data: { values: ['供应商甲', '供应商乙', ''] },
}))
})
it('非法字段或超大筛选返回 400', async () => {
const response = await post({ field: 'imageUrl' })
expect(response.status).toBe(400)
// 超过 200KB 体积上限的筛选选择
const huge = { field: 'brand', columnFilters: { brand: { mode: 'include', values: Array.from({ length: 5000 }, (_, index) => `值${index}x`.padEnd(40, 'x')) } } }
const tooLarge = await post(huge)
expect(tooLarge.status).toBe(400)
})
it('要求登录后访问', async () => {
const request = { json: async () => ({ field: 'brand' }), headers: { get: () => '10' } } as any
await POST(request)
expect(mockHandleRequest).toHaveBeenCalledWith(request, expect.any(Function), { requireAuth: true })
})
})

View File

@ -0,0 +1,42 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import {
FINISHED_PRODUCT_COLUMN_FILTER_FIELDS,
finishedProductProgressColumnFiltersSchema,
normalizeFinishedProductProgressColumnFilters,
} from '@/lib/finished-product-progress'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
const MAX_FILTER_BYTES = 200_000
const bodySchema = z.object({
field: z.enum(FINISHED_PRODUCT_COLUMN_FILTER_FIELDS),
search: z.string().trim().max(200).optional().default(''),
supplier: z.array(z.string().trim().min(1).max(200)).max(100).optional().default([]),
brand: z.array(z.string().trim().min(1).max(200)).max(100).optional().default([]),
currentNode: z.array(z.string().trim().min(1).max(200)).max(20).optional().default([]),
dueDateFrom: z.string().trim().max(20).optional().default(''),
dueDateTo: z.string().trim().max(20).optional().default(''),
columnFilters: finishedProductProgressColumnFiltersSchema.optional().default({}),
}).strict()
// POST:列头筛选候选值(带着当前全部筛选条件计算,排除该列自身;JSON body 以支持较大的列筛选选择)。
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
if (!body || Buffer.byteLength(JSON.stringify(body), 'utf8') > MAX_FILTER_BYTES) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件过多,请缩小选择范围', 400, ctx.requestId)
}
const parsed = bodySchema.safeParse(body)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选参数不正确', 400, ctx.requestId)
const { field, ...filters } = parsed.data
const data = await finishedProductProgressService.listColumnFilterOptions({
field,
filters: { ...filters, columnFilters: normalizeFinishedProductProgressColumnFilters(filters.columnFilters) },
user: ctx.user,
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { parseFinishedProductProgressColumnFilters } from '@/lib/finished-product-progress'
import { finishedProductProgressService } from '@/server/services/finished-product-progress.service'
const listSchema = z.object({
@ -13,6 +14,7 @@ const listSchema = z.object({
currentNode: z.array(z.string().trim().min(1).max(200)).max(20).default([]),
dueDateFrom: z.string().trim().max(20).default(''),
dueDateTo: z.string().trim().max(20).default(''),
columnFilters: z.string().max(200_000).optional(),
})
export async function GET(request: NextRequest) {
@ -24,8 +26,13 @@ export async function GET(request: NextRequest) {
currentNode: request.nextUrl.searchParams.getAll('currentNode'),
})
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '成品进度筛选条件不正确', 400, ctx.requestId)
const { page, pageSize, ...filters } = parsed.data
return jsonSuccess(await finishedProductProgressService.listPage({ page, pageSize, filters, user: ctx.user }), 200, ctx.requestId)
const { page, pageSize, columnFilters, ...filters } = parsed.data
return jsonSuccess(await finishedProductProgressService.listPage({
page,
pageSize,
filters: { ...filters, columnFilters: parseFinishedProductProgressColumnFilters(columnFilters) },
user: ctx.user,
}), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { AlertTriangle, ArrowLeft, ArrowRight, CalendarClock, Link2, Plus, Search, X } from 'lucide-react'
import {
@ -11,12 +11,15 @@ import {
SAMPLE_PROGRESS_STATUS_LABELS,
SAMPLE_PROGRESS_STATUSES,
} from '@/lib/sample-progress'
import { matchesColumnHeaderFilterSelection, toColumnFilterValue, type ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
import type { SampleProgressDTO } from '@/server/services/sample-progress.service'
import { ColumnHeaderFilter } from '@/components/common/ColumnHeaderFilter'
import SampleProgressColumnSettings from './components/SampleProgressColumnSettings'
import SampleProgressEditorDrawer from './components/SampleProgressEditorDrawer'
import SampleProgressTableRow from './components/SampleProgressTableRow'
import { isSampleProgressSupplierOverdue, sampleProgressSuppliers } from './sampleProgressSuppliers'
import { useSampleProgressColumnVisibility } from './useSampleProgressColumnVisibility'
import type { SampleProgressColumnKey } from './sampleProgressColumns'
const PAGE_SIZE = 20
const utcToday = () => {
@ -26,11 +29,27 @@ const utcToday = () => {
const isOverdue = (item: SampleProgressDTO) => sampleProgressSuppliers(item)
.some((supplier) => isSampleProgressSupplierOverdue(supplier, utcToday()))
// 列头筛选的行取值:供应商/快递单号/进度说明是供应商子行多值列(任一子行命中即算命中)。
export const SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS: Partial<Record<SampleProgressColumnKey, (item: SampleProgressDTO) => string[]>> = {
openingCode: (item) => [toColumnFilterValue(item.openingCode)],
brand: (item) => [toColumnFilterValue(item.brand)],
designer: (item) => [toColumnFilterValue(item.designer)],
purchaseOwner: (item) => [toColumnFilterValue(item.purchaseOwner)],
merchandiserOwner: (item) => [toColumnFilterValue(item.merchandiserOwner)],
status: (item) => [SAMPLE_PROGRESS_STATUS_LABELS[normalizeSampleProgressStatus(item.status)] ?? ''],
productSpec: (item) => [toColumnFilterValue(item.productSize)],
material: (item) => [toColumnFilterValue(item.material)],
supplier: (item) => sampleProgressSuppliers(item).map((supplier) => toColumnFilterValue(supplier.supplier)),
courierTrackingNumber: (item) => sampleProgressSuppliers(item).map((supplier) => toColumnFilterValue(supplier.courierTrackingNumber)),
progressDescription: (item) => sampleProgressSuppliers(item).map((supplier) => toColumnFilterValue(supplier.progressDescription)),
}
export default function SampleProgressClient({ initialItems, currentUserName, currentUserRole, currentSampleStageOpeningCodes = [], embedded = false, permissionScope, permissionCanOperate = true, permissionCanEditAllRecords = false }: { initialItems: SampleProgressDTO[]; currentUserName: string; currentUserRole?: string; currentSampleStageOpeningCodes?: string[]; embedded?: boolean; permissionScope?: 'supply-chain'; permissionCanOperate?: boolean; permissionCanEditAllRecords?: boolean }) {
const [items, setItems] = useState(initialItems)
const [keyword, setKeyword] = useState('')
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([])
const [overdueOnly, setOverdueOnly] = useState(false)
const [columnFilters, setColumnFilters] = useState<Partial<Record<SampleProgressColumnKey, ColumnHeaderFilterSelection>>>({})
const [page, setPage] = useState(1)
const [pageInput, setPageInput] = useState('1')
const [editorItem, setEditorItem] = useState<SampleProgressDTO | 'new' | null>(null)
@ -43,8 +62,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
const canDelete = permissionCanOperate && isSampleProgressAdministrator(currentUserRole)
const currentStageCodes = useMemo(() => new Set(currentSampleStageOpeningCodes.map((code) => code.trim().toUpperCase())), [currentSampleStageOpeningCodes])
const currentStageItems = useMemo(() => items.filter((item) => currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(item.status)), [currentStageCodes, items])
const filtered = useMemo(() => items.filter((item) => {
const baseFiltered = useMemo(() => items.filter((item) => {
const supplierKeywords = sampleProgressSuppliers(item).flatMap((supplier) => [supplier.supplier, supplier.courierTrackingNumber, supplier.progressDescription])
const haystack = [item.openingCode, item.brand, item.designer, item.purchaseOwner, item.color, item.material, ...supplierKeywords].join(' ').toLowerCase()
const normalizedStatus = normalizeSampleProgressStatus(item.status)
@ -55,6 +73,29 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
: currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(normalizedStatus)
return (!keyword || haystack.includes(keyword.toLowerCase())) && statusMatches && (!overdueOnly || isOverdue(item))
}), [currentStageCodes, items, keyword, overdueOnly, selectedStatuses])
const currentStageItems = useMemo(() => items.filter((item) => currentStageCodes.has(item.openingCode.trim().toUpperCase()) && !isSampleProgressTerminalStatus(item.status)), [currentStageCodes, items])
const filtered = useMemo(() => baseFiltered.filter((item) => Object.entries(columnFilters).every(([key, selection]) =>
!selection || matchesColumnHeaderFilterSelection(selection, SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[key as SampleProgressColumnKey]?.(item) ?? []),
)), [baseFiltered, columnFilters])
// 列头筛选候选值:当前其余筛选条件下(不含该列自身)的去重值,空值以空字符串展示为「(空值)」。
const loadColumnFilterCandidates = useCallback((key: SampleProgressColumnKey) => {
const extract = SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[key]
if (!extract) return Promise.resolve([])
const values = baseFiltered
.filter((item) => Object.entries(columnFilters).every(([filterKey, selection]) =>
!selection || filterKey === key || matchesColumnHeaderFilterSelection(selection, SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[filterKey as SampleProgressColumnKey]?.(item) ?? [])))
.flatMap((item) => extract(item))
return Promise.resolve([...new Set(values)].sort((left, right) => left.localeCompare(right, 'zh-CN')))
}, [baseFiltered, columnFilters])
const applyColumnFilter = useCallback((key: SampleProgressColumnKey, selection: ColumnHeaderFilterSelection | null) => {
setColumnFilters((current) => {
const next = { ...current }
if (selection) next[key] = selection
else delete next[key]
return next
})
setPage(1)
}, [])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const rows = filtered.slice((Math.min(page, totalPages) - 1) * PAGE_SIZE, Math.min(page, totalPages) * PAGE_SIZE)
const overdueCount = currentStageItems.filter(isOverdue).length
@ -95,6 +136,7 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
setKeyword('')
setSelectedStatuses([])
setOverdueOnly(false)
setColumnFilters({})
setPage(1)
}
@ -160,7 +202,10 @@ export default function SampleProgressClient({ initialItems, currentUserName, cu
<div className="box-border min-h-0 flex-1 overflow-auto overscroll-contain pb-[76px]">
<table className="w-full border-separate border-spacing-0 text-center text-[11px]" style={{ minWidth: tableMinWidth }}>
<thead className="text-[11px] font-bold text-[#657074]">
<tr>{columnVisibility.visibleColumns.map((column) => <th key={column.key} data-column-key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-black/[0.07] bg-[#eef0ef] px-3 py-3 ${column.headerClassName ?? ''}`}>{column.label}</th>)}</tr>
<tr>{columnVisibility.visibleColumns.map((column) => {
const extractor = SAMPLE_PROGRESS_COLUMN_FILTER_EXTRACTORS[column.key]
return <th key={column.key} data-column-key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-black/[0.07] bg-[#eef0ef] px-3 py-3 ${column.headerClassName ?? ''}`}>{extractor ? <span className="inline-flex items-center gap-1"><span>{column.label}</span><ColumnHeaderFilter label={column.label} active={Boolean(columnFilters[column.key])} disabled={editorItem !== null} loadValues={() => loadColumnFilterCandidates(column.key)} selection={columnFilters[column.key] ?? null} onApply={(selection) => applyColumnFilter(column.key, selection)} /></span> : column.label}</th>
})}</tr>
</thead>
<tbody>
{rows.map((item) => <SampleProgressTableRow key={item.id} item={item} visibleColumnKeys={columnVisibility.visibleColumnKeySet} overdue={isOverdue(item)} canEdit={canEdit(item)} canDelete={canDelete} onStartEdit={() => setEditorItem(item)} onView={setViewItem} onHistory={setHistoryItem} onDelete={(row) => void remove(row)} onPreview={setPreviewImage}/>) }

View File

@ -5,11 +5,14 @@ import { useCallback, useRef, useState } from 'react'
import { FileDown, Layers3, Plus, RotateCcw, Search, Trash2, Upload, X } from 'lucide-react'
import {
EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS,
serializeFinishedProductProgressColumnFilters,
type FinishedProductProgressChangeLogEntry,
type FinishedProductProgressColumnFilterField,
type FinishedProductProgressDetailRow,
type FinishedProductProgressFilters,
type FinishedProductProgressPage,
} from '@/lib/finished-product-progress'
import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
import { FinishedProductExportProgress } from './components/FinishedProductExportProgress'
@ -64,10 +67,13 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
Object.entries(nextFilters).forEach(([key, value]) => {
const { columnFilters: columnFiltersValue, ...restFilters } = nextFilters
Object.entries(restFilters).forEach(([key, value]) => {
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
else if (value) params.set(key, value)
})
const serializedColumnFilters = serializeFinishedProductProgressColumnFilters(columnFiltersValue)
if (serializedColumnFilters) params.set('columnFilters', serializedColumnFilters)
const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage
setData(next)
setFilters(nextFilters)
@ -79,6 +85,33 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
const afterImport = useCallback(() => load(filters, 1, data.pageInfo.pageSize), [data.pageInfo.pageSize, filters, load])
const importTask = useFinishedProductImport(afterImport)
// 列头筛选:候选值按当前已生效的全部筛选条件(不含该列自身)在服务端去重计算。
const loadColumnFilterValues = useCallback(async (field: FinishedProductProgressColumnFilterField) => {
const applied = appliedFiltersRef.current
const response = await fetch('/api/supply-chain/finished-product-progress/filter-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
field,
search: applied.search,
supplier: applied.supplier,
brand: applied.brand,
currentNode: applied.currentNode,
dueDateFrom: applied.dueDateFrom,
dueDateTo: applied.dueDateTo,
columnFilters: applied.columnFilters,
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '候选值加载失败')
return Array.isArray(payload.data?.values) ? payload.data.values as string[] : []
}, [])
const applyColumnFilter = useCallback((field: FinishedProductProgressColumnFilterField, selection: ColumnHeaderFilterSelection | null) => {
const nextColumnFilters = { ...appliedFiltersRef.current.columnFilters }
if (selection) nextColumnFilters[field] = selection
else delete nextColumnFilters[field]
void load({ ...appliedFiltersRef.current, columnFilters: nextColumnFilters }, 1, data.pageInfo.pageSize)
}, [data.pageInfo.pageSize, load])
const reset = () => {
const next = { ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS }
setFilters(next)
@ -160,7 +193,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
<button type="submit" className="finished-primary-button"><Search size={15} />查询</button><button type="button" title="重置筛选" aria-label="重置筛选" onClick={reset} className="finished-control-button"><RotateCcw size={15} /></button>
</form>
<section className="finished-product-table-shell">
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} selectedIds={selectedIds} allSelected={allSelected} onToggleSelect={(id) => setSelectedIds((current) => {
<FinishedProductTable rows={data.rows} columns={columns.visibleColumns} canEdit={canEdit} selectedIds={selectedIds} allSelected={allSelected} columnFilters={filters.columnFilters} onLoadColumnFilterValues={loadColumnFilterValues} onApplyColumnFilter={applyColumnFilter} onToggleSelect={(id) => setSelectedIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)

View File

@ -2,16 +2,21 @@
import Image from 'next/image'
import { ExternalLink, Eye, History, ListChecks, Pencil, Trash2 } from 'lucide-react'
import type { FinishedProductProgressDetailRow, FinishedProductProgressRow, FinishedProductProgressSummaryRow } from '@/lib/finished-product-progress'
import { FINISHED_PRODUCT_COLUMN_FILTER_FIELDS, type FinishedProductProgressColumnFilterField, type FinishedProductProgressColumnFilters, type FinishedProductProgressDetailRow, type FinishedProductProgressRow, type FinishedProductProgressSummaryRow } from '@/lib/finished-product-progress'
import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
import { ColumnHeaderFilter } from '@/components/common/ColumnHeaderFilter'
import type { FinishedProductProgressColumn, FinishedProductProgressColumnKey } from '../finishedProductProgressColumns'
export function FinishedProductTable({ rows, columns, canEdit, selectedIds, allSelected, onToggleSelect, onToggleGroup, onToggleAll, onEdit, onView, onHistory, onPreview, onDelete }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; selectedIds: ReadonlySet<string>; allSelected: boolean; onToggleSelect: (id: string) => void; onToggleGroup?: (ids: string[]) => void; onToggleAll: () => void; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) {
export function FinishedProductTable({ rows, columns, canEdit, selectedIds, allSelected, columnFilters = {}, onLoadColumnFilterValues = () => Promise.resolve([]), onApplyColumnFilter = () => {}, onToggleSelect, onToggleGroup, onToggleAll, onEdit, onView, onHistory, onPreview, onDelete }: { rows: FinishedProductProgressSummaryRow[]; columns: readonly FinishedProductProgressColumn[]; canEdit: boolean; selectedIds: ReadonlySet<string>; allSelected: boolean; columnFilters?: FinishedProductProgressColumnFilters; onLoadColumnFilterValues?: (field: FinishedProductProgressColumnFilterField) => Promise<string[]>; onApplyColumnFilter?: (field: FinishedProductProgressColumnFilterField, selection: ColumnHeaderFilterSelection | null) => void; onToggleSelect: (id: string) => void; onToggleGroup?: (ids: string[]) => void; onToggleAll: () => void; onEdit: (row: FinishedProductProgressRow) => void; onView: (row: FinishedProductProgressRow) => void; onHistory: (row: FinishedProductProgressRow) => void; onPreview: (row: FinishedProductProgressRow) => void; onDelete: (row: FinishedProductProgressRow) => void }) {
const minWidth = columns.reduce((total, column) => total + column.minWidth, 0)
const detailCount = rows.reduce((total, group) => total + group.details.length, 0)
const tableRows = buildFinishedProductTableRows(rows)
const filterableFields = new Set<string>(FINISHED_PRODUCT_COLUMN_FILTER_FIELDS)
const applyColumnFilter = onApplyColumnFilter ?? (() => {})
const loadColumnFilterValues = onLoadColumnFilterValues ?? (() => Promise.resolve([]))
return <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
<table className="w-full border-separate border-spacing-0 text-center text-xs" style={{ minWidth: minWidth + (canEdit ? 44 : 0) }}>
<thead className="text-[11px] font-bold text-zinc-600"><tr>{canEdit && <th aria-label="选择" style={{ width: 44, minWidth: 44, maxWidth: 44 }} className="sticky left-0 top-0 z-40 w-11 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-2 py-3"><input type="checkbox" checked={allSelected} disabled={detailCount === 0} onChange={onToggleAll} aria-label="全选当前页" /></th>}{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${shiftStickyClass(column.stickyClassName, canEdit)}`}>{column.label}</th>)}</tr></thead>
<thead className="text-[11px] font-bold text-zinc-600"><tr>{canEdit && <th aria-label="选择" style={{ width: 44, minWidth: 44, maxWidth: 44 }} className="sticky left-0 top-0 z-40 w-11 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-2 py-3"><input type="checkbox" checked={allSelected} disabled={detailCount === 0} onChange={onToggleAll} aria-label="全选当前页" /></th>}{columns.map((column) => <th key={column.key} style={{ minWidth: column.minWidth }} className={`sticky top-0 z-20 whitespace-nowrap border-b border-zinc-200 bg-[#eef0ef] px-3 py-3 ${shiftStickyClass(column.stickyClassName, canEdit)}`}>{filterableFields.has(column.key) ? <span className="inline-flex items-center gap-1"><span>{column.label}</span><ColumnHeaderFilter label={column.label} active={Boolean(columnFilters[column.key as FinishedProductProgressColumnFilterField])} loadValues={() => loadColumnFilterValues(column.key as FinishedProductProgressColumnFilterField)} selection={columnFilters[column.key as FinishedProductProgressColumnFilterField] ?? null} onApply={(selection) => applyColumnFilter(column.key as FinishedProductProgressColumnFilterField, selection)} /></span> : column.label}</th>)}</tr></thead>
<tbody>{tableRows.map(({ row, isContractFirst, contractRowSpan, contractRowIds, contractBandClass }) => <tr key={row.id} className={`group hover:bg-zinc-50 ${isContractFirst ? 'finished-contract-start' : ''}`}>
{canEdit && <td className={`finished-product-select-cell sticky left-0 z-10 border-b border-zinc-100 px-2 py-3 align-middle group-hover:bg-zinc-50 ${contractBandClass}`}><input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)} aria-label={`选择 ${row.barcode || row.itemNumber || row.id}`} /></td>}
{columns.map((column) => column.key === 'orderSequenceNo'

View File

@ -1,7 +1,7 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import type { FinishedProductProgressDetailRow, FinishedProductProgressFilters, FinishedProductProgressPage } from '@/lib/finished-product-progress'
import { serializeFinishedProductProgressColumnFilters, type FinishedProductProgressDetailRow, type FinishedProductProgressFilters, type FinishedProductProgressPage } from '@/lib/finished-product-progress'
const PAGE_SIZE = 200
@ -42,10 +42,13 @@ export function useFinishedProductExport() {
let total = Number.POSITIVE_INFINITY
do {
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) })
Object.entries(filters).forEach(([key, value]) => {
const { columnFilters: columnFiltersValue, ...restFilters } = filters
Object.entries(restFilters).forEach(([key, value]) => {
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
else if (value) params.set(key, value)
})
const serializedColumnFilters = serializeFinishedProductProgressColumnFilters(columnFiltersValue)
if (serializedColumnFilters) params.set('columnFilters', serializedColumnFilters)
const response = await fetch(`/api/supply-chain/finished-product-progress?${params.toString()}`, { cache: 'no-store' })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '导出数据拉取失败')

View File

@ -0,0 +1,195 @@
.columnFilterTrigger {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: #71717a;
cursor: pointer;
vertical-align: middle;
}
.columnFilterTrigger:hover,
.columnFilterActive {
background: #eff6ff;
color: #2563eb;
}
.columnFilterPopover {
position: fixed;
z-index: 125;
display: flex;
width: min(300px, calc(100vw - 16px));
flex-direction: column;
gap: 8px;
margin: 0;
padding: 10px;
border: 1px solid #d4d4d8;
border-radius: 6px;
background: #fff;
box-shadow: 0 10px 24px rgba(24, 24, 27, .12);
color: #27272a;
font-size: 12px;
letter-spacing: 0;
text-align: left;
}
.columnFilterHeading,
.columnFilterSelection,
.columnFilterActions {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.columnFilterHeading button {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #71717a;
cursor: pointer;
}
.columnFilterSearch {
display: flex;
height: 32px;
flex: 0 0 auto;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #d4d4d8;
border-radius: 4px;
color: #71717a;
}
.columnFilterSearch:focus-within {
border-color: #71717a;
}
.columnFilterSearch input {
width: 100%;
min-width: 0;
border: 0;
outline: none;
background: transparent;
color: #27272a;
font: inherit;
}
.columnFilterSelection label,
.columnFilterOption {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
}
.columnFilterPopover input[type='checkbox'] {
width: 14px;
height: 14px;
/* The whole label is clickable; keep the mark independent of global touch-target minimums. */
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
flex: 0 0 auto;
margin: 0;
accent-color: #27272a;
}
.columnFilterSelection button {
padding: 0;
border: 0;
background: transparent;
color: #2563eb;
font: inherit;
cursor: pointer;
}
.columnFilterCount {
color: #71717a;
font-size: 11px;
}
.columnFilterError {
color: #b91c1c;
font-size: 12px;
}
.columnFilterOptions {
height: 224px;
min-height: 32px;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
}
.columnFilterOption {
height: 32px;
padding: 0 5px;
border-radius: 4px;
}
.columnFilterOption:hover,
.columnFilterOption:focus-within {
background: #f4f4f5;
}
.columnFilterOption span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.columnFilterMessage {
display: flex;
min-height: 96px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
color: #71717a;
}
.columnFilterActions {
padding-top: 8px;
border-top: 1px solid #e4e4e7;
}
.columnFilterActions button {
display: inline-flex;
height: 30px;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 10px;
border: 1px solid #d4d4d8;
border-radius: 4px;
background: #fff;
color: #52525b;
font: inherit;
cursor: pointer;
}
.columnFilterActions .columnFilterApply {
border-color: #27272a;
background: #27272a;
color: #fff;
}
.columnFilterPopover button:disabled {
cursor: not-allowed;
opacity: .5;
}

View File

@ -0,0 +1,89 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { ColumnHeaderFilter } from './ColumnHeaderFilter'
const loadValues = jest.fn()
const baseProps = {
label: '供应商',
active: false,
loadValues,
selection: null,
onApply: jest.fn(),
}
describe('ColumnHeaderFilter', () => {
beforeEach(() => {
jest.clearAllMocks()
loadValues.mockResolvedValue(['供应商甲', '供应商乙', ''])
})
async function open() {
fireEvent.click(screen.getByRole('button', { name: '筛选供应商' }))
await screen.findByRole('checkbox', { name: '供应商甲' })
return screen.getByRole('dialog', { name: '筛选供应商' })
}
it('弹窗展示候选值,空值显示为(空值)且可勾选', async () => {
render(<ColumnHeaderFilter {...baseProps} />)
const dialog = await open()
expect(within(dialog).getByRole('checkbox', { name: '(空值)' })).toBeInTheDocument()
expect(within(dialog).getByText('已选 3 / 3 项')).toBeInTheDocument()
})
it('先全不选再勾选候选值,应用回传 include 选择', async () => {
const onApply = jest.fn()
render(<ColumnHeaderFilter {...baseProps} onApply={onApply} />)
const dialog = await open()
fireEvent.click(within(dialog).getByRole('button', { name: '全不选' }))
fireEvent.click(within(dialog).getByRole('checkbox', { name: '供应商甲' }))
fireEvent.click(within(dialog).getByRole('button', { name: '应用' }))
expect(onApply).toHaveBeenCalledWith({ mode: 'include', values: ['供应商甲'] })
})
it('exclude 模式下未勾选任何值时应用等于清除(回传 null)', async () => {
const onApply = jest.fn()
render(<ColumnHeaderFilter {...baseProps} onApply={onApply} />)
const dialog = await open()
fireEvent.click(within(dialog).getByRole('button', { name: '应用' }))
expect(onApply).toHaveBeenCalledWith(null)
})
it('默认 exclude 语义:直接排除所选值', async () => {
const onApply = jest.fn()
render(<ColumnHeaderFilter {...baseProps} onApply={onApply} />)
const dialog = await open()
fireEvent.click(within(dialog).getByRole('checkbox', { name: '供应商乙' }))
fireEvent.click(within(dialog).getByRole('button', { name: '应用' }))
expect(onApply).toHaveBeenCalledWith({ mode: 'exclude', values: ['供应商乙'] })
})
it('清除筛选按钮直接回传 null', async () => {
const onApply = jest.fn()
render(<ColumnHeaderFilter {...baseProps} selection={{ mode: 'include', values: ['供应商甲'] }} onApply={onApply} />)
const dialog = await open()
fireEvent.click(within(dialog).getByRole('button', { name: '清除筛选' }))
expect(onApply).toHaveBeenCalledWith(null)
})
it('搜索框过滤候选值并支持全选搜索结果', async () => {
const onApply = jest.fn()
render(<ColumnHeaderFilter {...baseProps} selection={{ mode: 'include', values: [] }} onApply={onApply} />)
const dialog = await open()
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: '供应商甲' } })
expect(within(dialog).getByRole('checkbox', { name: '供应商甲' })).toBeInTheDocument()
expect(within(dialog).queryByRole('checkbox', { name: '供应商乙' })).not.toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('checkbox', { name: '全选搜索结果' }))
fireEvent.click(within(dialog).getByRole('button', { name: '应用' }))
expect(onApply).toHaveBeenCalledWith({ mode: 'include', values: ['供应商甲'] })
})
it('候选值加载失败时展示错误与重试', async () => {
loadValues.mockRejectedValueOnce(new Error('候选值加载失败'))
render(<ColumnHeaderFilter {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: '筛选供应商' }))
expect(await screen.findByRole('alert')).toHaveTextContent('候选值加载失败')
loadValues.mockResolvedValueOnce(['供应商甲'])
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByRole('checkbox', { name: '供应商甲' })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,239 @@
'use client'
import { Check, Filter, LoaderCircle, Search, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { ColumnHeaderFilterSelection } from '@/lib/column-header-filters'
import styles from './ColumnHeaderFilter.module.css'
type DraftSelection = { mode: 'include' | 'exclude'; values: string[] }
const ROW_HEIGHT = 32
const WINDOW_SIZE = 18
/**
* 通用列头搜索筛选(交互与 1688代发数据明细 的 DropshipHeaderFilter 一致):
* 漏斗图标 → 弹窗(搜索 + 全选/全不选 + 候选值复选列表 + 已选计数 + 应用/清除筛选)。
* 候选值由调用方提供(loadValues),应用结果回传给调用方;exclude+空值 = 清除筛选。
*/
export function ColumnHeaderFilter({
label,
active,
disabled = false,
loadValues,
selection,
onApply,
}: {
label: string
active: boolean
disabled?: boolean
loadValues: () => Promise<string[]>
selection: ColumnHeaderFilterSelection | null
onApply: (selection: ColumnHeaderFilterSelection | null) => void
}) {
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState<DraftSelection>({ mode: 'exclude', values: [] })
const [search, setSearch] = useState('')
const [values, setValues] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [retry, setRetry] = useState(0)
const [scrollTop, setScrollTop] = useState(0)
const [position, setPosition] = useState({ left: 0, top: 0, maxHeight: 410 })
const triggerRef = useRef<HTMLButtonElement>(null)
const popupRef = useRef<HTMLFormElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const allRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const draftSet = useMemo(() => new Set(draft.values), [draft.values])
const matches = useMemo(() => {
const query = search.trim().toLocaleLowerCase()
return values.filter((value) => (value || '(空值)').toLocaleLowerCase().includes(query))
}, [search, values])
const isChecked = (value: string) => draft.mode === 'exclude' ? !draftSet.has(value) : draftSet.has(value)
const matchingChecked = matches.reduce((count, value) => count + Number(isChecked(value)), 0)
const selectedCount = values.reduce((count, value) => count + Number(isChecked(value)), 0)
const allChecked = matches.length > 0 && matchingChecked === matches.length
const windowStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 3)
useEffect(() => {
if (allRef.current) allRef.current.indeterminate = matchingChecked > 0 && !allChecked
}, [allChecked, matchingChecked, open, loading])
useEffect(() => {
if (!open) return
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const candidates = await loadValues()
if (!controller.signal.aborted) {
setValues(Array.isArray(candidates) ? candidates : [])
setLoading(false)
}
} catch (reason) {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : '候选值加载失败')
setLoading(false)
}
}
}
void load()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, retry])
useEffect(() => {
if (!open) return
const place = () => {
const bounds = triggerRef.current?.getBoundingClientRect()
if (!bounds) return
const width = Math.min(300, window.innerWidth - 16)
const below = window.innerHeight - bounds.bottom - 12
const above = bounds.top - 12
const maxHeight = Math.min(410, Math.max(below, above))
setPosition({
left: Math.max(8, Math.min(bounds.right - width, window.innerWidth - width - 8)),
top: below >= Math.min(410, above) ? bounds.bottom + 6 : Math.max(8, bounds.top - maxHeight - 6),
maxHeight,
})
}
const outside = (event: PointerEvent) => {
if (!popupRef.current?.contains(event.target as Node) && !triggerRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
setOpen(false)
triggerRef.current?.focus()
}
}
place()
searchRef.current?.focus()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
document.addEventListener('pointerdown', outside)
document.addEventListener('keydown', escape)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
document.removeEventListener('pointerdown', outside)
document.removeEventListener('keydown', escape)
}
}, [open])
function toggle(value: string) {
setDraft((current) => ({
...current,
values: current.values.includes(value)
? current.values.filter((item) => item !== value) : [...current.values, value],
}))
}
function toggleAll() {
if (!search.trim()) {
setDraft({ mode: allChecked ? 'include' : 'exclude', values: [] })
return
}
const next = new Set(draft.values)
for (const value of matches) {
if ((draft.mode === 'include') === !allChecked) next.add(value)
else next.delete(value)
}
setDraft({ ...draft, values: [...next] })
}
function apply() {
// exclude 且未勾选任何值 = 清除筛选;include 且未勾选任何值 = 只看空集(与代发数据明细语义一致)。
onApply(draft.mode === 'exclude' && draft.values.length === 0 ? null : { mode: draft.mode, values: draft.values })
setOpen(false)
triggerRef.current?.focus()
}
return (
<>
<button
aria-label={`筛选${label}`}
aria-expanded={open}
aria-haspopup="dialog"
className={`${styles.columnFilterTrigger} ${active ? styles.columnFilterActive : ''}`}
disabled={disabled}
onClick={() => {
setDraft(selection ?? { mode: 'exclude', values: [] })
setSearch('')
setScrollTop(0)
setValues([])
setOpen(!open)
}}
ref={triggerRef}
title={`筛选${label}${active ? '(已筛选)' : ''}`}
type="button"
>
<Filter size={13} />
</button>
{open && createPortal(
<form
aria-label={`筛选${label}`}
className={styles.columnFilterPopover}
onSubmit={(event) => { event.preventDefault(); if (!loading && !error) apply() }}
ref={popupRef}
role="dialog"
style={{ ...position, maxHeight: position.maxHeight }}
>
<div className={styles.columnFilterHeading}>
<strong>{label}</strong>
<button aria-label="关闭筛选" onClick={() => { setOpen(false); triggerRef.current?.focus() }} title="关闭" type="button"><X size={15} /></button>
</div>
<label className={styles.columnFilterSearch}>
<Search size={14} />
<input
aria-label={`搜索${label}候选值`}
onChange={(event) => {
setSearch(event.target.value)
setScrollTop(0)
if (listRef.current) listRef.current.scrollTop = 0
}}
placeholder={`搜索${label}`}
ref={searchRef}
value={search}
/>
</label>
<div className={styles.columnFilterSelection}>
<label>
<input checked={allChecked} disabled={loading || !!error || !matches.length} onChange={toggleAll} ref={allRef} type="checkbox" />
<span>{search.trim() ? '全选搜索结果' : '全选'}</span>
</label>
<button disabled={loading || !!error} onClick={() => setDraft({ mode: 'include', values: [] })} type="button">全不选</button>
</div>
<div className={styles.columnFilterCount}>{loading ? '加载中' : error ? '加载失败' : `已选 ${selectedCount} / ${values.length} 项`}</div>
<div
aria-label={`${label}候选值`}
className={styles.columnFilterOptions}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
ref={listRef}
>
{loading ? <div className={styles.columnFilterMessage}><LoaderCircle size={16} />正在加载候选值</div>
: error ? <div className={styles.columnFilterMessage} role="alert">{error}<button onClick={() => setRetry(retry + 1)} type="button">重试</button></div>
: !matches.length ? <div className={styles.columnFilterMessage}>没有匹配的候选值</div>
: <div style={{ height: matches.length * ROW_HEIGHT, position: 'relative' }}>
<div style={{ position: 'absolute', top: windowStart * ROW_HEIGHT, left: 0, right: 0 }}>
{matches.slice(windowStart, windowStart + WINDOW_SIZE).map((value) => (
<label className={styles.columnFilterOption} key={value || '__EMPTY__'}>
<input checked={isChecked(value)} onChange={() => toggle(value)} type="checkbox" />
<span title={value || '(空值)'}>{value || '(空值)'}</span>
</label>
))}
</div>
</div>}
</div>
<div className={styles.columnFilterActions}>
<button disabled={loading || !!error} onClick={() => { onApply(null); setOpen(false); triggerRef.current?.focus() }} type="button">清除筛选</button>
<button className={styles.columnFilterApply} disabled={loading || !!error} type="submit"><Check size={14} />应用</button>
</div>
</form>, document.body,
)}
</>
)
}

View File

@ -0,0 +1,60 @@
export {}
import { describe, expect, it } from '@jest/globals'
import {
matchesColumnHeaderFilterSelection,
normalizeColumnHeaderFilterSelection,
serializeColumnHeaderFilters,
toColumnFilterValue,
type ColumnHeaderFilterSelection,
} from './column-header-filters'
describe('column-header-filters', () => {
it('include 模式命中任一所选值即通过(多值列如供应商子行)', () => {
const selection: ColumnHeaderFilterSelection = { mode: 'include', values: ['供应商甲', '供应商乙'] }
expect(matchesColumnHeaderFilterSelection(selection, ['供应商乙'])).toBe(true)
expect(matchesColumnHeaderFilterSelection(selection, ['供应商丙', ''])).toBe(false)
})
it('exclude 模式为全不命中才通过', () => {
const selection: ColumnHeaderFilterSelection = { mode: 'exclude', values: ['DF-A1'] }
expect(matchesColumnHeaderFilterSelection(selection, ['DF-B1'])).toBe(true)
expect(matchesColumnHeaderFilterSelection(selection, ['DF-A1', 'DF-B1'])).toBe(false)
})
it('空值以空字符串参与筛选,可被 include/exclude', () => {
const includeEmpty: ColumnHeaderFilterSelection = { mode: 'include', values: [''] }
expect(matchesColumnHeaderFilterSelection(includeEmpty, [''])).toBe(true)
expect(matchesColumnHeaderFilterSelection(includeEmpty, ['有值'])).toBe(false)
const excludeEmpty: ColumnHeaderFilterSelection = { mode: 'exclude', values: [''] }
expect(matchesColumnHeaderFilterSelection(excludeEmpty, ['有值'])).toBe(true)
expect(matchesColumnHeaderFilterSelection(excludeEmpty, [''])).toBe(false)
})
it('无筛选时全部通过', () => {
expect(matchesColumnHeaderFilterSelection(null, ['任意'])).toBe(true)
expect(matchesColumnHeaderFilterSelection(undefined, [])).toBe(true)
})
it('normalize:exclude 空值视为无筛选,值去重排序', () => {
expect(normalizeColumnHeaderFilterSelection({ mode: 'exclude', values: [] })).toBeNull()
expect(normalizeColumnHeaderFilterSelection({ mode: 'include', values: [] })).toEqual({ mode: 'include', values: [] })
expect(normalizeColumnHeaderFilterSelection({ mode: 'include', values: ['b', 'a', 'b'] }))
.toEqual({ mode: 'include', values: ['a', 'b'] })
})
it('serialize 只保留白名单字段并跳过空筛选', () => {
expect(serializeColumnHeaderFilters({
sku: { mode: 'exclude', values: ['X'] },
spu: { mode: 'exclude', values: [] },
barcode: null,
extra: { mode: 'include', values: ['Y'] },
}, ['sku', 'spu', 'barcode'])).toBe('{"sku":{"mode":"exclude","values":["X"]}}')
})
it('toColumnFilterValue 把 null/undefined 归一为空字符串并去首尾空白', () => {
expect(toColumnFilterValue(null)).toBe('')
expect(toColumnFilterValue(undefined)).toBe('')
expect(toColumnFilterValue(' 值 ')).toBe('值')
})
})

View File

@ -0,0 +1,55 @@
import { z } from 'zod'
/**
* 列头搜索筛选的通用模型与匹配逻辑。
* 交互对齐 1688代发数据明细 已上线的 DropshipHeaderFilter:
* include = 命中任一所选值;exclude = 排除所选值;空值以空字符串参与筛选、展示为「(空值)」。
*/
export const COLUMN_HEADER_FILTER_MAX_VALUES = 100_000
export const columnHeaderFilterSelectionSchema = z.object({
mode: z.enum(['include', 'exclude']),
values: z.array(z.string().max(2_048)).max(COLUMN_HEADER_FILTER_MAX_VALUES),
}).strict()
export type ColumnHeaderFilterSelection = z.infer<typeof columnHeaderFilterSelectionSchema>
export function normalizeColumnHeaderFilterSelection(
selection: ColumnHeaderFilterSelection | null | undefined,
): ColumnHeaderFilterSelection | null {
if (!selection) return null
const values = [...new Set(selection.values.map((value) => String(value ?? '')))].sort((left, right) => left.localeCompare(right, 'zh-CN'))
// exclude 且未勾选任何值等于没有筛选。
if (selection.mode === 'exclude' && values.length === 0) return null
return { mode: selection.mode, values }
}
export function serializeColumnHeaderFilters(
filters: Record<string, ColumnHeaderFilterSelection | null | undefined>,
allowedFields: readonly string[],
): string {
const normalized: Record<string, ColumnHeaderFilterSelection> = {}
for (const field of allowedFields) {
const selection = normalizeColumnHeaderFilterSelection(filters[field])
if (selection) normalized[field] = selection
}
return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : ''
}
/** 行值匹配:extractedValues 为该行在该列下的全部取值(多值列如供应商子行会返回多个)。 */
export function matchesColumnHeaderFilterSelection(
selection: ColumnHeaderFilterSelection | null | undefined,
extractedValues: string[],
): boolean {
if (!selection) return true
if (selection.values.length === 0) return selection.mode === 'include'
const candidates = extractedValues.map((value) => String(value ?? ''))
const hit = candidates.some((value) => selection.values.includes(value))
return selection.mode === 'include' ? hit : !hit
}
/** 把行值归一成可比较的字符串(null/undefined 归一为空字符串)。 */
export function toColumnFilterValue(value: unknown): string {
return String(value ?? '').trim()
}

View File

@ -1,5 +1,11 @@
import { z } from 'zod'
import { pinyin } from 'pinyin-pro'
import {
columnHeaderFilterSelectionSchema,
normalizeColumnHeaderFilterSelection,
serializeColumnHeaderFilters,
type ColumnHeaderFilterSelection,
} from '@/lib/column-header-filters'
export const FINISHED_PRODUCT_PROGRESS_FIELDS = [
{ key: 'orderPlacedDate', label: '下单日期' },
@ -169,6 +175,7 @@ export const EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS = {
currentNode: [] as string[],
dueDateFrom: '',
dueDateTo: '',
columnFilters: {},
} as const
export type FinishedProductProgressFilters = {
@ -178,6 +185,59 @@ export type FinishedProductProgressFilters = {
currentNode: string[]
dueDateFrom: string
dueDateTo: string
columnFilters: FinishedProductProgressColumnFilters
}
// 列头搜索筛选支持的列(图片/日期/数量/操作列不参与)。
export const FINISHED_PRODUCT_COLUMN_FILTER_FIELDS = [
'orderSequenceNo',
'contractNumber',
'followUpOwner',
'itemNumber',
'barcode',
'productName',
'supplier',
'brand',
'remarks',
'currentNode',
] as const
export type FinishedProductProgressColumnFilterField = typeof FINISHED_PRODUCT_COLUMN_FILTER_FIELDS[number]
export type FinishedProductProgressColumnFilters = {
[K in FinishedProductProgressColumnFilterField]?: ColumnHeaderFilterSelection
}
// 宽松 record:键在 normalize 时按白名单过滤,避免要求枚举键全量出现。
export const finishedProductProgressColumnFiltersSchema = z.record(
z.string(),
columnHeaderFilterSelectionSchema,
)
export function normalizeFinishedProductProgressColumnFilters(value: unknown): FinishedProductProgressColumnFilters {
if (!value || typeof value !== 'object') return {}
const source = value as Record<string, unknown>
const normalized: FinishedProductProgressColumnFilters = {}
for (const field of FINISHED_PRODUCT_COLUMN_FILTER_FIELDS) {
const parsed = columnHeaderFilterSelectionSchema.safeParse(source[field])
if (!parsed.success) continue
const selection = normalizeColumnHeaderFilterSelection(parsed.data)
if (selection) normalized[field] = selection
}
return normalized
}
export function parseFinishedProductProgressColumnFilters(value: string | null | undefined): FinishedProductProgressColumnFilters {
if (!value) return {}
try {
return normalizeFinishedProductProgressColumnFilters(JSON.parse(value))
} catch {
return {}
}
}
export function serializeFinishedProductProgressColumnFilters(filters: FinishedProductProgressColumnFilters): string {
return serializeColumnHeaderFilters(filters, FINISHED_PRODUCT_COLUMN_FILTER_FIELDS)
}
export const finishedProductContractGroupUpdateSchema = z.object({

View File

@ -1,5 +1,12 @@
import { Prisma } from '@prisma/client'
import { finishedProductSpuBase, resolveFinishedProductCurrentNode, type FinishedProductProgressFilters, type FinishedProductProgressInput } from '@/lib/finished-product-progress'
import {
finishedProductSpuBase,
resolveFinishedProductCurrentNode,
type FinishedProductProgressColumnFilterField,
type FinishedProductProgressFilters,
type FinishedProductProgressInput,
} from '@/lib/finished-product-progress'
import { matchesColumnHeaderFilterSelection, toColumnFilterValue } from '@/lib/column-header-filters'
import { isFactoryIntakeRecord } from '@/lib/finished-product-progress-source'
export const DELIVERY_RECORD_SELECT = {
@ -59,6 +66,29 @@ export function matchesCurrentNode(row: Pick<FinishedProductProgressRecord, 'con
return filters.currentNode.length === 0 || filters.currentNode.includes(resolveFinishedProductCurrentNode(row.contractNumber, row.orderedQuantity, row.deliveryQuantity, row.orderPlacedDate))
}
/** 列头筛选的行取值:currentNode 是派生值,必须内存计算;其余列直接取记录字段。 */
export function finishedProductColumnFilterValueOf(row: FinishedProductProgressRecord, field: FinishedProductProgressColumnFilterField): string {
if (field === 'currentNode') {
return resolveFinishedProductCurrentNode(row.contractNumber, row.orderedQuantity, row.deliveryQuantity, row.orderPlacedDate)
}
return toColumnFilterValue(row[field])
}
/** 应用列头筛选(include=命中任一所选值 / exclude=排除所选值;excludeField 用于候选值计算时跳过该列自身)。 */
export function matchesFinishedProductColumnFilters(
row: FinishedProductProgressRecord,
filters: FinishedProductProgressFilters,
excludeField?: FinishedProductProgressColumnFilterField,
) {
for (const [field, selection] of Object.entries(filters.columnFilters ?? {})) {
if (!selection || field === excludeField) continue
if (!matchesColumnHeaderFilterSelection(selection, [finishedProductColumnFilterValueOf(row, field as FinishedProductProgressColumnFilterField)])) {
return false
}
}
return true
}
export function dataValues(input: FinishedProductProgressInput) {
const { orderDueDateChangeReason: _reason, supplierCode, ...mutableInput } = input
return {

View File

@ -5,6 +5,7 @@ import {
buildOrderSequenceNo,
buildOrderSequencePrefix,
resolveFinishedProductCurrentNode,
type FinishedProductProgressColumnFilterField,
type FinishedProductProgressFilters,
type FinishedProductProgressInput,
} from '@/lib/finished-product-progress'
@ -13,7 +14,7 @@ import { isFactoryIntakeRecord } from '@/lib/finished-product-progress-source'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
ITEM_SELECT, DELIVERY_RECORD_SELECT, buildWhere, matchesCurrentNode, dataValues, sortFinishedProductRowsForList,
ITEM_SELECT, DELIVERY_RECORD_SELECT, buildWhere, matchesCurrentNode, matchesFinishedProductColumnFilters, finishedProductColumnFilterValueOf, dataValues, sortFinishedProductRowsForList,
} from '@/server/repositories/finished-product-progress-record'
export { ITEM_SELECT, dataValues, sortFinishedProductRowsForList } from '@/server/repositories/finished-product-progress-record'
export type { FinishedProductProgressRecord, FinishedProductDeliveryRecord } from '@/server/repositories/finished-product-progress-record'
@ -61,7 +62,7 @@ export class FinishedProductProgressRepository {
],
})
// 行身份重设计后每条记录即一笔独立订单,平铺返回
const sortedRows = sortFinishedProductRowsForList(rows.filter((row) => matchesCurrentNode(row, params.filters)))
const sortedRows = sortFinishedProductRowsForList(rows.filter((row) => matchesCurrentNode(row, params.filters) && matchesFinishedProductColumnFilters(row, params.filters)))
const pagedRows = sortedRows.slice(params.skip, params.skip + params.take)
return {
groups: pagedRows.map((row) => [row]),
@ -69,6 +70,16 @@ export class FinishedProductProgressRepository {
}
}
// 列头筛选候选值:按当前筛选过滤(跳过该列自身的列筛选)后去重,空值以空字符串参与。
async listColumnFilterValues(params: { filters: FinishedProductProgressFilters; field: FinishedProductProgressColumnFilterField }) {
const where = buildWhere(params.filters)
const rows = await prisma.finishedProductProgressItem.findMany({ where, select: ITEM_SELECT })
const values = rows
.filter((row) => matchesCurrentNode(row, params.filters) && matchesFinishedProductColumnFilters(row, params.filters, params.field))
.map((row) => finishedProductColumnFilterValueOf(row, params.field))
return [...new Set(values)].sort((left, right) => left.localeCompare(right, 'zh-CN'))
}
async listOptions() {
const [suppliers, brands, currentNodes] = await Promise.all([
prisma.finishedProductProgressItem.findMany({ select: { supplier: true }, distinct: ['supplier'], orderBy: { supplier: 'asc' } }),

View File

@ -5,7 +5,9 @@ import {
extractOrderPlacedDateFromContractNumber,
extractOrderPlacedDateFromRemarks,
finishedProductContractGroupUpdateSchema,
normalizeFinishedProductProgressColumnFilters,
type FinishedProductProgressChange,
type FinishedProductProgressColumnFilterField,
type FinishedProductProgressFilters,
type FinishedProductProgressImportProgress,
type FinishedProductProgressSummaryRow,
@ -43,6 +45,7 @@ function cleanFilters(input?: Partial<FinishedProductProgressFilters>): Finished
currentNode: list(input?.currentNode),
dueDateFrom: String(input?.dueDateFrom ?? '').trim().slice(0, 200),
dueDateTo: String(input?.dueDateTo ?? '').trim().slice(0, 200),
columnFilters: normalizeFinishedProductProgressColumnFilters(input?.columnFilters),
}
}
@ -128,6 +131,18 @@ export class FinishedProductProgressService {
}
}
// 列头筛选候选值:按当前筛选条件(不含该列自身的列筛选)过滤后去重。
async listColumnFilterOptions(params: {
field: FinishedProductProgressColumnFilterField
filters?: Partial<FinishedProductProgressFilters>
user: AuthUser
}) {
await this.requireAccess(params.user, 'view')
const filters = cleanFilters(params.filters)
const values = await this.repository.listColumnFilterValues({ filters, field: params.field })
return { values }
}
async create(input: unknown, user: AuthUser) {
await this.requireAccess(user, 'edit')
const values = initializeDueDates(await finishedProductDirectoryService.normalizeInput(parseInput(input)))