788 lines
30 KiB
TypeScript
788 lines
30 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'
|
||
import type { ApiResponse } from '@/server/core/types'
|
||
import type {
|
||
ProductArchiveFilterOptions,
|
||
ProductArchiveFilters,
|
||
SupplyChainProductArchiveRow,
|
||
} from '@/lib/supply-chain-product-archive'
|
||
import { ProductArchiveImagePreview } from '@/app/supply-chain/database/product-archive/ProductArchiveImagePreview'
|
||
import {
|
||
LIMIT_TABLE_COLUMN_MAP,
|
||
LIMIT_TABLE_GROUP_LABELS,
|
||
LIMIT_TABLE_COLUMNS,
|
||
readLimitTableCell,
|
||
type LimitTableColumn,
|
||
type LimitTableGroupKey,
|
||
} from './limit-table-columns'
|
||
import { LimitFilterSelect } from './LimitFilterSelect'
|
||
import { LimitTableHeaderCell, type LimitTableSort } from './LimitTableHeaderCell'
|
||
import { LimitTableSettingsMenu } from './LimitTableSettingsMenu'
|
||
import { requestDownloadCenterRefresh } from '@/components/layout/download-center-events'
|
||
import {
|
||
COMBO_LIMIT_FILTER_FIELDS,
|
||
LIMIT_TABLE_FILTER_FIELDS,
|
||
LIMIT_TABLE_FILTER_FIELD_MAP,
|
||
useLimitTableSettings,
|
||
type LimitTableFilterField,
|
||
} from './useLimitTableSettings'
|
||
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE } from '@/lib/supply-chain-product-archive'
|
||
import { useLimitPriceData } from './useLimitPriceData'
|
||
import { useComboLimitData } from './useComboLimitData'
|
||
import { ComboLimitSection } from './ComboLimitSection'
|
||
import type { ComboLimitFilters, ComboLimitSort } from '@/lib/supply-chain-combo-limit-price'
|
||
import styles from './limit-price.module.css'
|
||
|
||
type ProductKind = 'bulk' | 'brand' | 'combo'
|
||
|
||
const PRODUCT_KIND_LABELS: Record<ProductKind, string> = {
|
||
bulk: '大货商品',
|
||
brand: '品牌商品',
|
||
combo: '组合限价',
|
||
}
|
||
|
||
/** 品牌商品 = 品牌含「顾家 / 索菲亚 / 朴美」;其余(含其他品牌)都归大货商品。 */
|
||
const BRAND_PRODUCT_KEYWORDS = ['顾家', '索菲亚', '朴美']
|
||
|
||
function isBrandedBrandName(brand: string) {
|
||
return BRAND_PRODUCT_KEYWORDS.some((keyword) => brand.includes(keyword))
|
||
}
|
||
|
||
const STATUS_CLASS: Record<string, string> = {
|
||
销售: styles.statusOnSale,
|
||
停产: styles.statusPaused,
|
||
退市: styles.statusOffSale,
|
||
}
|
||
|
||
const EMPTY_FILTERS: ProductArchiveFilters = { productStatus: ['销售'] }
|
||
|
||
/** 大货、品牌商品顶部固定筛选,始终置于筛选栏最前。 */
|
||
const FRONT_FILTER_FIELDS: LimitTableFilterField[] = [
|
||
{ key: 'spu', label: 'SPU' },
|
||
{ key: 'sku', label: 'SKU' },
|
||
{ key: 'barcode', label: '商品编码' },
|
||
]
|
||
|
||
/** 已选筛选条件回显用的字段名(表头筛选用列标签,其余用这里兜底)。 */
|
||
const EXTRA_FILTER_LABELS: Record<string, string> = {
|
||
keyword: '关键词',
|
||
normalCodeOnly: '只看正常编码',
|
||
salesPeriod: '销售统计周期',
|
||
salesGranularity: '统计粒度',
|
||
}
|
||
|
||
/** 与筛选条件无关的展示型参数,不参与「已选条件」回显。 */
|
||
const FILTER_CHIP_IGNORED_KEYS = new Set(['salesGranularity', 'salesPeriod'])
|
||
|
||
function LimitProductImageCell({
|
||
row,
|
||
onPreview,
|
||
}: {
|
||
row: SupplyChainProductArchiveRow
|
||
onPreview: (image: string, title: string) => void
|
||
}) {
|
||
// 优先走同源代理(避免 CDN 防盗链),代理不支持的来源回退到原始地址。
|
||
const candidates = useMemo(() => {
|
||
const sources: string[] = []
|
||
if (row.image.trim()) {
|
||
sources.push(`/api/supply-chain/limit-management/source-image?${new URLSearchParams({ barcode: row.barcode }).toString()}`)
|
||
const direct = row.image.trim()
|
||
if (/^https?:\/\//i.test(direct) || direct.startsWith('/')) sources.push(direct)
|
||
}
|
||
return sources
|
||
}, [row.barcode, row.image])
|
||
const [candidateIndex, setCandidateIndex] = useState(0)
|
||
const src = candidates[candidateIndex]
|
||
if (!src) {
|
||
return <span className={styles.imageEmpty}>无图</span>
|
||
}
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={styles.imageButton}
|
||
title="点击查看大图"
|
||
aria-label={`查看${row.productName || row.barcode}图片`}
|
||
onClick={() => onPreview(src, `${row.productName || row.barcode}(${row.barcode})`)}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
key={src}
|
||
src={src}
|
||
alt=""
|
||
loading="lazy"
|
||
referrerPolicy="no-referrer"
|
||
className={styles.imageThumb}
|
||
onError={() => setCandidateIndex((index) => index + 1)}
|
||
/>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function LimitCell({
|
||
row,
|
||
column,
|
||
onPreviewImage,
|
||
}: {
|
||
row: SupplyChainProductArchiveRow
|
||
column: LimitTableColumn
|
||
onPreviewImage: (image: string, title: string) => void
|
||
}) {
|
||
if (column.kind === 'image') {
|
||
return <LimitProductImageCell row={row} onPreview={onPreviewImage} />
|
||
}
|
||
if (column.kind === 'placeholder') {
|
||
return <span className={styles.placeholderCell} title="数据来源待接入">—</span>
|
||
}
|
||
const value = readLimitTableCell(row, column)
|
||
if (!value) {
|
||
return <span className={styles.placeholderCell}>—</span>
|
||
}
|
||
if (column.key === 'productStatus') {
|
||
return <span className={`${styles.statusChip} ${STATUS_CLASS[value] ?? styles.statusPaused}`}>{value}</span>
|
||
}
|
||
return <span title={value}>{value}</span>
|
||
}
|
||
|
||
export function LimitPriceTab({ userId, canExport = false }: { userId: string; canExport?: boolean }) {
|
||
const {
|
||
settings,
|
||
hydrated,
|
||
toggleColumn,
|
||
setColumnVisible,
|
||
moveColumn,
|
||
resetColumns,
|
||
toggleFilterField,
|
||
resetFilterFields,
|
||
toggleComboFilterField,
|
||
resetComboFilterFields,
|
||
setCompact,
|
||
setPageSize,
|
||
setColumnWidths,
|
||
} = useLimitTableSettings(userId)
|
||
|
||
const [productKind, setProductKind] = useState<ProductKind>('bulk')
|
||
const [filters, setFilters] = useState<ProductArchiveFilters>(EMPTY_FILTERS)
|
||
const [comboFilters, setComboFilters] = useState<ComboLimitFilters>({})
|
||
const [comboSort, setComboSort] = useState<ComboLimitSort>({
|
||
field: 'ownerLimitPrice',
|
||
direction: 'desc',
|
||
})
|
||
// 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。
|
||
const [liveWidths, setLiveWidths] = useState<Record<string, number> | null>(null)
|
||
const [page, setPage] = useState(1)
|
||
const [pageInput, setPageInput] = useState('1')
|
||
const [brandPartition, setBrandPartition] = useState<{ bulk: string[]; brand: string[] } | null>(null)
|
||
const [brandPartitionSettled, setBrandPartitionSettled] = useState(false)
|
||
const [imagePreview, setImagePreview] = useState<{ image: string; title: string } | null>(null)
|
||
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null)
|
||
const [sort, setSort] = useState<LimitTableSort>({
|
||
key: 'ownerLimitPrice',
|
||
direction: 'desc',
|
||
})
|
||
const [openSortKey, setOpenSortKey] = useState<string | null>(null)
|
||
|
||
// 大货/品牌两个子界面的划分依据:品牌字段(含顾家/索菲亚/朴美 → 品牌;其余 → 大货)
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
let cancelled = false
|
||
async function load() {
|
||
try {
|
||
const params = new URLSearchParams({ field: 'brand', limit: '200' })
|
||
const response = await fetch(
|
||
`/api/supply-chain/limit-management/options?${params.toString()}`,
|
||
{ signal: controller.signal },
|
||
)
|
||
const payload = (await response.json()) as ApiResponse<ProductArchiveFilterOptions>
|
||
if (!response.ok || !payload.success || !payload.data) throw new Error('品牌候选值加载失败')
|
||
const values = payload.data.values
|
||
if (!cancelled) setBrandPartition({
|
||
brand: values.filter(isBrandedBrandName),
|
||
bulk: values.filter((value) => !isBrandedBrandName(value)),
|
||
})
|
||
} catch {
|
||
if (!cancelled && !controller.signal.aborted) setBrandPartition(null)
|
||
} finally {
|
||
if (!cancelled && !controller.signal.aborted) setBrandPartitionSettled(true)
|
||
}
|
||
}
|
||
void load()
|
||
return () => {
|
||
cancelled = true
|
||
controller.abort()
|
||
}
|
||
}, [])
|
||
|
||
const effectiveFilters = useMemo(() => {
|
||
const next: ProductArchiveFilters = { ...filters }
|
||
const tabBrands = productKind === 'combo' ? [] : (brandPartition?.[productKind] ?? [])
|
||
if (productKind === 'bulk' || productKind === 'brand') {
|
||
next.limitProductKind = productKind
|
||
}
|
||
if (tabBrands.length) {
|
||
const selected = filters.brand ?? []
|
||
const intersection = selected.length
|
||
? selected.filter((value) => tabBrands.includes(value))
|
||
: tabBrands
|
||
next.brand = intersection.length ? intersection : ['__no_matching_brand__']
|
||
}
|
||
return next
|
||
}, [brandPartition, filters, productKind])
|
||
|
||
const { rows, pageInfo, loading, error, reload } = useLimitPriceData(
|
||
page,
|
||
settings.pageSize,
|
||
effectiveFilters,
|
||
hydrated && brandPartitionSettled && productKind !== 'combo',
|
||
)
|
||
const comboPageSize: 50 | 100 = settings.pageSize === 50 ? 50 : 100
|
||
const comboData = useComboLimitData(
|
||
page,
|
||
comboPageSize,
|
||
comboFilters,
|
||
comboSort,
|
||
hydrated && productKind === 'combo',
|
||
)
|
||
const activeLoading = productKind === 'combo' ? comboData.loading : loading
|
||
const activeTotalPages = productKind === 'combo' ? comboData.pageInfo.totalPages : pageInfo.totalPages
|
||
|
||
// 限价明细导出:两种(不含图片 / 含图片),走全局下载中心后台生成,
|
||
// 进度显示在右上角下载中心里,页面不再挂浮动进度卡。
|
||
// 使用 filters(用户主动选择的筛选条件,如商品状态/品牌/类目等);
|
||
// 「大货 / 品牌」页签的分区不参与导出——导出始终包含大货 + 品牌全部数据;
|
||
// columnSet=limit 让导出抬头与限价界面完全一致(在下载中心执行器里固定)。
|
||
const [creatingExport, setCreatingExport] = useState<'plain' | 'image' | 'combo' | null>(null)
|
||
const [exportMessage, setExportMessage] = useState<string | null>(null)
|
||
useEffect(() => {
|
||
if (!exportMessage) return
|
||
const timer = setTimeout(() => setExportMessage(null), 6000)
|
||
return () => clearTimeout(timer)
|
||
}, [exportMessage])
|
||
const createLimitExportTask = useCallback(async (includeImage: boolean) => {
|
||
setCreatingExport(includeImage ? 'image' : 'plain')
|
||
setExportMessage(null)
|
||
try {
|
||
const response = await fetch('/api/downloads', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ type: 'limit-export', params: { filters, includeImage } }),
|
||
})
|
||
const payload = await response.json().catch(() => null)
|
||
if (!response.ok || !payload?.success) {
|
||
throw new Error(payload?.error?.message || '创建导出任务失败')
|
||
}
|
||
requestDownloadCenterRefresh()
|
||
setExportMessage('已加入下载中心,点击右上角下载图标查看进度')
|
||
} catch (error) {
|
||
setExportMessage(error instanceof Error ? error.message : '创建导出任务失败')
|
||
} finally {
|
||
setCreatingExport(null)
|
||
}
|
||
}, [filters])
|
||
|
||
const createComboLimitExportTask = useCallback(async () => {
|
||
setCreatingExport('combo')
|
||
setExportMessage(null)
|
||
try {
|
||
const response = await fetch('/api/downloads', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type: 'combo-limit-export',
|
||
params: { filters: comboFilters, sort: comboSort },
|
||
}),
|
||
})
|
||
const payload = await response.json().catch(() => null)
|
||
if (!response.ok || !payload?.success) {
|
||
throw new Error(payload?.error?.message || '创建组合限价导出任务失败')
|
||
}
|
||
requestDownloadCenterRefresh()
|
||
setExportMessage('已加入下载中心,点击右上角下载图标查看进度')
|
||
} catch (error) {
|
||
setExportMessage(error instanceof Error ? error.message : '创建组合限价导出任务失败')
|
||
} finally {
|
||
setCreatingExport(null)
|
||
}
|
||
}, [comboFilters, comboSort])
|
||
|
||
// 单页排序:只对当前页已加载的数据排序(与截图里的「升序(单页)」口径一致)。
|
||
const displayRows = useMemo(() => {
|
||
if (!sort) return rows
|
||
const column = LIMIT_TABLE_COLUMN_MAP.get(sort.key)
|
||
if (!column) return rows
|
||
const factor = sort.direction === 'asc' ? 1 : -1
|
||
return rows
|
||
.map((row, index) => ({ row, index }))
|
||
.sort((left, right) => {
|
||
const a = readLimitTableCell(left.row, column)
|
||
const b = readLimitTableCell(right.row, column)
|
||
if (!a && !b) return left.index - right.index
|
||
if (!a) return 1
|
||
if (!b) return -1
|
||
if (column.numeric) {
|
||
const aNumber = Number(a.replace(/[%,\s]/g, ''))
|
||
const bNumber = Number(b.replace(/[%,\s]/g, ''))
|
||
if (Number.isFinite(aNumber) && Number.isFinite(bNumber) && aNumber !== bNumber) {
|
||
return (aNumber - bNumber) * factor
|
||
}
|
||
}
|
||
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true }) * factor
|
||
})
|
||
.map((item) => item.row)
|
||
}, [rows, sort])
|
||
|
||
useEffect(() => {
|
||
if (!openSortKey) return
|
||
const onDocumentMouseDown = (event: MouseEvent) => {
|
||
const target = event.target as HTMLElement | null
|
||
if (target?.closest('[data-limit-sort-cell]') || target?.closest('[data-limit-sort-menu]')) return
|
||
setOpenSortKey(null)
|
||
}
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') setOpenSortKey(null)
|
||
}
|
||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||
document.addEventListener('keydown', onKeyDown)
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||
document.removeEventListener('keydown', onKeyDown)
|
||
}
|
||
}, [openSortKey])
|
||
|
||
const visibleColumns = useMemo(
|
||
() => settings.columns
|
||
.filter((setting) => setting.visible)
|
||
.map((setting) => LIMIT_TABLE_COLUMN_MAP.get(setting.key))
|
||
.filter((column): column is LimitTableColumn => Boolean(column)),
|
||
[settings.columns],
|
||
)
|
||
|
||
// 相邻同分组列合并成一级表头;用户把列拖到别的分组后面时会形成新的分组段。
|
||
const headerGroups = useMemo(() => {
|
||
const groups: Array<{ group: LimitTableGroupKey; span: number }> = []
|
||
for (const column of visibleColumns) {
|
||
const last = groups[groups.length - 1]
|
||
if (last && last.group === column.group) last.span += 1
|
||
else groups.push({ group: column.group, span: 1 })
|
||
}
|
||
return groups
|
||
}, [visibleColumns])
|
||
|
||
const totalWidth = useMemo(
|
||
() => visibleColumns.reduce((sum, column) => sum + (liveWidths?.[column.key] ?? settings.widths[column.key] ?? column.width), 0),
|
||
[visibleColumns, liveWidths, settings.widths],
|
||
)
|
||
|
||
const columnWidth = useCallback(
|
||
(column: LimitTableColumn) => liveWidths?.[column.key] ?? settings.widths[column.key] ?? column.width,
|
||
[liveWidths, settings.widths],
|
||
)
|
||
|
||
/** 列头右缘拖拽调整列宽。 */
|
||
const startHeaderResize = (column: LimitTableColumn, event: { clientX: number; preventDefault: () => void; stopPropagation: () => void }) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
const startX = event.clientX
|
||
const startWidth = columnWidth(column)
|
||
let latest = startWidth
|
||
setLiveWidths({ ...settings.widths })
|
||
const onMove = (moveEvent: MouseEvent) => {
|
||
latest = Math.max(48, Math.min(600, Math.round(startWidth + moveEvent.clientX - startX)))
|
||
setLiveWidths((current) => ({ ...(current ?? settings.widths), [column.key]: latest }))
|
||
}
|
||
const onUp = () => {
|
||
window.removeEventListener('mousemove', onMove)
|
||
window.removeEventListener('mouseup', onUp)
|
||
setColumnWidths({ ...settings.widths, [column.key]: latest })
|
||
setLiveWidths(null)
|
||
}
|
||
window.addEventListener('mousemove', onMove)
|
||
window.addEventListener('mouseup', onUp)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (page > activeTotalPages) setPage(Math.max(1, activeTotalPages))
|
||
}, [activeTotalPages, page])
|
||
|
||
useEffect(() => {
|
||
setPageInput(String(page))
|
||
}, [page])
|
||
|
||
const commitPageInput = () => {
|
||
const parsed = Number(pageInput)
|
||
if (!Number.isFinite(parsed)) {
|
||
setPageInput(String(page))
|
||
return
|
||
}
|
||
const next = Math.min(Math.max(Math.trunc(parsed), 1), Math.max(1, pageInfo.totalPages))
|
||
setPageInput(String(next))
|
||
if (next !== page) setPage(next)
|
||
}
|
||
|
||
const changeProductKind = (kind: ProductKind) => {
|
||
setProductKind(kind)
|
||
setPage(1)
|
||
setFilters((current) => {
|
||
if (!current.brand?.length) return current
|
||
const next = { ...current }
|
||
delete next.brand
|
||
return next
|
||
})
|
||
}
|
||
|
||
const updateFilterValue = (key: string, values: string[]) => {
|
||
setPage(1)
|
||
setFilters((current) => {
|
||
const next = { ...current }
|
||
if (values.length) (next as Record<string, unknown>)[key] = values
|
||
else delete (next as Record<string, unknown>)[key]
|
||
return next
|
||
})
|
||
}
|
||
|
||
// 已选筛选条件回显:字段名 + 「属于」具体取值,支持逐个清除与一键清空。
|
||
const activeFilterChips = useMemo(() => {
|
||
const labelByKey = new Map<string, string>()
|
||
for (const field of LIMIT_TABLE_FILTER_FIELDS) labelByKey.set(field.key, field.label)
|
||
for (const field of FRONT_FILTER_FIELDS) labelByKey.set(field.key, field.label)
|
||
for (const column of LIMIT_TABLE_COLUMNS) {
|
||
if (column.filterKey) labelByKey.set(column.filterKey, column.label)
|
||
}
|
||
const chips: Array<{ key: string; label: string; text: string }> = []
|
||
for (const [key, raw] of Object.entries(filters)) {
|
||
if (FILTER_CHIP_IGNORED_KEYS.has(key)) continue
|
||
const values = (Array.isArray(raw) ? raw : [])
|
||
.map((value) => String(value).trim())
|
||
.filter(Boolean)
|
||
if (values.length === 0) continue
|
||
const display = values.map((value) => value === PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE ? '空值' : value)
|
||
chips.push({
|
||
key,
|
||
label: labelByKey.get(key) ?? EXTRA_FILTER_LABELS[key] ?? key,
|
||
text: display.length > 3
|
||
? `${display.slice(0, 3).join(',')}…(共 ${display.length} 个)`
|
||
: display.join(','),
|
||
})
|
||
}
|
||
return chips
|
||
}, [filters])
|
||
|
||
const clearAllFilters = () => {
|
||
setPage(1)
|
||
setFilters({})
|
||
}
|
||
|
||
const visibleFilterFields = useMemo(() => {
|
||
const fixedKeys = new Set(FRONT_FILTER_FIELDS.map((field) => field.key))
|
||
const configured = settings.filterFields
|
||
.filter((key) => !fixedKeys.has(key))
|
||
.map((key) => LIMIT_TABLE_FILTER_FIELD_MAP.get(key))
|
||
.filter((field): field is NonNullable<typeof field> => Boolean(field))
|
||
return [...FRONT_FILTER_FIELDS, ...configured]
|
||
}, [settings.filterFields])
|
||
const filterContext = useMemo(
|
||
() => filters as Record<string, string[]>,
|
||
[filters],
|
||
)
|
||
|
||
return (
|
||
<section className={styles.tabPanel} aria-label="限价商品列表">
|
||
<div className={styles.toolbar}>
|
||
<div className={styles.segmented} role="group" aria-label="商品类型">
|
||
{(Object.keys(PRODUCT_KIND_LABELS) as ProductKind[]).map((kind) => (
|
||
<button
|
||
key={kind}
|
||
type="button"
|
||
aria-pressed={productKind === kind}
|
||
className={productKind === kind ? styles.segmentActive : undefined}
|
||
onClick={() => changeProductKind(kind)}
|
||
>
|
||
{PRODUCT_KIND_LABELS[kind]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.iconButton}
|
||
title="刷新"
|
||
aria-label={productKind === 'combo' ? '刷新组合限价' : '刷新限价商品'}
|
||
onClick={() => productKind === 'combo' ? comboData.reload() : reload()}
|
||
>
|
||
<RefreshCw size={15} className={activeLoading ? 'animate-spin' : undefined} />
|
||
</button>
|
||
{productKind !== 'combo' && canExport && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={styles.exportButton}
|
||
disabled={creatingExport !== null}
|
||
onClick={() => void createLimitExportTask(false)}
|
||
>
|
||
{creatingExport === 'plain' ? '提交中…' : '导出明细'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.exportButton}
|
||
disabled={creatingExport !== null}
|
||
onClick={() => void createLimitExportTask(true)}
|
||
>
|
||
{creatingExport === 'image' ? '提交中…' : '导出明细(含图片)'}
|
||
</button>
|
||
</>
|
||
)}
|
||
{productKind === 'combo' && canExport && (
|
||
<button
|
||
type="button"
|
||
className={styles.exportButton}
|
||
disabled={creatingExport !== null}
|
||
onClick={() => void createComboLimitExportTask()}
|
||
>
|
||
{creatingExport === 'combo' ? '提交中…' : '导出组合限价'}
|
||
</button>
|
||
)}
|
||
{exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
|
||
<span className={styles.toolbarSpacer} />
|
||
<LimitTableSettingsMenu
|
||
compact={settings.compact}
|
||
onCompactChange={setCompact}
|
||
columns={settings.columns}
|
||
filterFields={productKind === 'combo' ? settings.comboFilterFields : settings.filterFields}
|
||
filterFieldDefinitions={productKind === 'combo' ? COMBO_LIMIT_FILTER_FIELDS : LIMIT_TABLE_FILTER_FIELDS}
|
||
showProductSettings={productKind !== 'combo'}
|
||
onToggleColumn={toggleColumn}
|
||
onSetColumnsVisible={setColumnVisible}
|
||
onMoveColumn={moveColumn}
|
||
onResetColumns={resetColumns}
|
||
onToggleFilterField={productKind === 'combo' ? toggleComboFilterField : toggleFilterField}
|
||
onResetFilterFields={productKind === 'combo' ? resetComboFilterFields : resetFilterFields}
|
||
/>
|
||
</div>
|
||
|
||
{productKind === 'combo' ? (
|
||
<ComboLimitSection
|
||
userId={userId}
|
||
rows={comboData.rows}
|
||
page={page}
|
||
pageSize={comboPageSize}
|
||
total={comboData.pageInfo.total}
|
||
totalPages={comboData.pageInfo.totalPages}
|
||
loading={comboData.loading}
|
||
error={comboData.error}
|
||
filters={comboFilters}
|
||
topFilterFieldKeys={settings.comboFilterFields}
|
||
sort={comboSort}
|
||
onPageChange={setPage}
|
||
onPageSizeChange={(size) => {
|
||
setPageSize(size)
|
||
setPage(1)
|
||
}}
|
||
onFiltersChange={setComboFilters}
|
||
onSortChange={setComboSort}
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className={styles.filterBar}>
|
||
<span className={styles.filterHint}>筛选</span>
|
||
{visibleFilterFields.length === 0 && (
|
||
<span className={styles.filterHint}>未配置筛选项,可在右上角「显示设置 → 筛选设置」中开启</span>
|
||
)}
|
||
{visibleFilterFields.map((field) => (
|
||
<LimitFilterSelect
|
||
key={field.key}
|
||
field={field}
|
||
selected={(filters as Record<string, string[] | undefined>)[field.key] ?? []}
|
||
contextFilters={filterContext}
|
||
onChange={(values) => updateFilterValue(field.key, values)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{activeFilterChips.length > 0 && (
|
||
<div className={styles.filterChips} aria-label="已选筛选条件">
|
||
<span className={styles.filterChipsLabel}>已选条件</span>
|
||
{activeFilterChips.map((chip) => (
|
||
<span key={chip.key} className={styles.filterChip}>
|
||
<b>{chip.label}</b>
|
||
<span className={styles.filterChipValue}>属于:{chip.text}</span>
|
||
<button
|
||
type="button"
|
||
className={styles.filterChipClear}
|
||
onClick={() => updateFilterValue(chip.key, [])}
|
||
title={`清除「${chip.label}」筛选`}
|
||
aria-label={`清除「${chip.label}」筛选`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<button type="button" className={styles.filterChipsReset} onClick={clearAllFilters}>
|
||
清空全部
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.tableScroll}>
|
||
<table
|
||
className={`${styles.table} ${loading && rows.length > 0 ? styles.tableLoading : ''}`}
|
||
style={{ width: totalWidth ? Math.max(totalWidth, 960) : 960 }}
|
||
>
|
||
<colgroup>
|
||
{visibleColumns.map((column) => (
|
||
<col key={column.key} style={{ width: columnWidth(column) }} />
|
||
))}
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
{headerGroups.map((group, index) => (
|
||
<th
|
||
key={`${group.group}-${index}`}
|
||
colSpan={group.span}
|
||
className={styles.groupTh}
|
||
scope="colgroup"
|
||
>
|
||
{LIMIT_TABLE_GROUP_LABELS[group.group]}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
<tr>
|
||
{visibleColumns.map((column) => (
|
||
column.kind === 'image' ? (
|
||
<th
|
||
key={column.key}
|
||
scope="col"
|
||
title={column.label}
|
||
className={`${styles.headTh} ${column.numeric ? styles.headThNumeric : ''}`}
|
||
>
|
||
{column.label}
|
||
<span
|
||
role="separator"
|
||
aria-label={`调整${column.label}列宽`}
|
||
className={styles.headResizeHandle}
|
||
onMouseDown={(event) => startHeaderResize(column, event)}
|
||
/>
|
||
</th>
|
||
) : (
|
||
<LimitTableHeaderCell
|
||
key={column.key}
|
||
column={column}
|
||
numeric={column.numeric}
|
||
sort={sort}
|
||
menuOpen={openSortKey === column.key}
|
||
onToggleMenu={() => setOpenSortKey((current) => (current === column.key ? null : column.key))}
|
||
onCloseMenu={() => setOpenSortKey(null)}
|
||
onSort={(direction) => {
|
||
setSort({ key: column.key, direction })
|
||
setOpenSortKey(null)
|
||
}}
|
||
onClearSort={() => {
|
||
setSort(null)
|
||
setOpenSortKey(null)
|
||
}}
|
||
filterField={column.filterKey
|
||
? {
|
||
key: column.filterKey,
|
||
label: column.label,
|
||
fixedOptions: LIMIT_TABLE_FILTER_FIELD_MAP.get(column.filterKey)?.fixedOptions,
|
||
}
|
||
: undefined}
|
||
filterValues={column.filterKey
|
||
? ((filters as Record<string, string[] | undefined>)[column.filterKey] ?? [])
|
||
: undefined}
|
||
filterContext={filterContext}
|
||
filterOpen={openFilterKey === column.key}
|
||
onToggleFilter={() => setOpenFilterKey((current) => (current === column.key ? null : column.key))}
|
||
onCloseFilter={() => setOpenFilterKey(null)}
|
||
onFilterChange={(values) => {
|
||
if (column.filterKey) updateFilterValue(column.filterKey, values)
|
||
}}
|
||
onResizeStart={(event) => startHeaderResize(column, event)}
|
||
/>
|
||
)
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className={settings.compact ? styles.rowCompact : undefined}>
|
||
{loading && rows.length === 0 && (
|
||
<tr className={styles.stateRow}><td colSpan={Math.max(visibleColumns.length, 1)}>加载中…</td></tr>
|
||
)}
|
||
{!loading && error && (
|
||
<tr className={styles.stateRow}><td colSpan={Math.max(visibleColumns.length, 1)}><span className={styles.errorState}>{error}</span></td></tr>
|
||
)}
|
||
{!loading && !error && rows.length === 0 && (
|
||
<tr className={styles.stateRow}><td colSpan={Math.max(visibleColumns.length, 1)}>没有符合条件的商品</td></tr>
|
||
)}
|
||
{displayRows.map((row) => (
|
||
<tr key={row.id}>
|
||
{visibleColumns.map((column) => (
|
||
<td
|
||
key={column.key}
|
||
className={`${styles.bodyTd} ${column.numeric ? styles.numericCell : ''}`}
|
||
>
|
||
<LimitCell row={row} column={column} onPreviewImage={(image, title) => setImagePreview({ image, title })} />
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className={styles.pagination}>
|
||
<button
|
||
type="button"
|
||
className={styles.pagerButton}
|
||
aria-label="上一页"
|
||
title="上一页"
|
||
disabled={page <= 1 || loading}
|
||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||
>
|
||
<ChevronLeft size={15} />
|
||
</button>
|
||
<input
|
||
className={styles.pageInput}
|
||
value={pageInput}
|
||
inputMode="numeric"
|
||
aria-label="页码"
|
||
onChange={(event) => setPageInput(event.target.value.replace(/[^\d]/g, ''))}
|
||
onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
|
||
onBlur={commitPageInput}
|
||
/>
|
||
<span className={styles.pageTotal}>/ {pageInfo.totalPages}</span>
|
||
<button
|
||
type="button"
|
||
className={styles.pagerButton}
|
||
aria-label="下一页"
|
||
title="下一页"
|
||
disabled={page >= pageInfo.totalPages || loading}
|
||
onClick={() => setPage((current) => Math.min(pageInfo.totalPages, current + 1))}
|
||
>
|
||
<ChevronRight size={15} />
|
||
</button>
|
||
<span className={styles.paginationSummary}>
|
||
共 {pageInfo.total} 条,每页
|
||
<select
|
||
className={styles.pageSizeSelect}
|
||
aria-label="每页条数"
|
||
value={settings.pageSize}
|
||
onChange={(event) => { setPageSize(Number(event.target.value) as 50 | 100 | 200); setPage(1) }}
|
||
>
|
||
<option value={50}>50</option>
|
||
<option value={100}>100</option>
|
||
<option value={200}>200</option>
|
||
</select>
|
||
条
|
||
</span>
|
||
</div>
|
||
|
||
<ProductArchiveImagePreview
|
||
image={imagePreview?.image ?? null}
|
||
title={imagePreview?.title ?? ''}
|
||
onClose={() => setImagePreview(null)}
|
||
/>
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|