726 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import { 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 {
LIMIT_TABLE_FILTER_FIELDS,
LIMIT_TABLE_FILTER_FIELD_MAP,
useLimitTableSettings,
} 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 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,
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>(null)
// 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。
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>(null)
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 (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' | 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 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 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 = settings.filterFields
.map((key) => LIMIT_TABLE_FILTER_FIELD_MAP.get(key))
.filter((field): field is NonNullable<typeof field> => Boolean(field))
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' && exportMessage ? <span className={styles.exportHint}>{exportMessage}</span> : null}
<span className={styles.toolbarSpacer} />
{productKind !== 'combo' && (
<LimitTableSettingsMenu
compact={settings.compact}
onCompactChange={setCompact}
columns={settings.columns}
filterFields={settings.filterFields}
onToggleColumn={toggleColumn}
onSetColumnsVisible={setColumnVisible}
onMoveColumn={moveColumn}
onResetColumns={resetColumns}
onToggleFilterField={toggleFilterField}
onResetFilterFields={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}
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>
)
}