Merge accepted product archive filters and sales toolbar
This commit is contained in:
commit
465f5d5916
Binary file not shown.
|
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { ARCHIVE_CODE_BATCH_LIMIT, parseArchiveCodeQuery } from './archiveCodeQuery'
|
||||
|
||||
export function ProductArchiveCodeBatchDialog({ label, initialText, onConfirm, onCancel }: {
|
||||
label: string
|
||||
initialText: string
|
||||
onConfirm: (values: string[]) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [text, setText] = useState(initialText)
|
||||
const { values, error } = useMemo(() => parseArchiveCodeQuery(text), [text])
|
||||
|
||||
const submit = () => {
|
||||
if (error || values.length === 0) return
|
||||
onConfirm(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen title={`${label} 批量查询`} size="small" variant="compact" showCloseButton={false} onClose={onCancel}
|
||||
footer={
|
||||
<div className="batch-actions">
|
||||
<button type="button" onClick={onCancel}>取消</button>
|
||||
<button type="button" className="primary" disabled={Boolean(error) || values.length === 0} onClick={submit}>
|
||||
查询{values.length > 0 ? ` ${values.length} 个` : ''}
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
<div className="batch-body">
|
||||
<span id="archive-code-batch-help" className="sr-only">
|
||||
每行一个,最多 {ARCHIVE_CODE_BATCH_LIMIT} 个。回车查询,Shift+回车换行。
|
||||
</span>
|
||||
<textarea
|
||||
aria-label={`${label}批量查询内容`}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'archive-code-batch-error' : 'archive-code-batch-help'}
|
||||
value={text}
|
||||
placeholder={`粘贴${label},每行一个,最多${ARCHIVE_CODE_BATCH_LIMIT}个`}
|
||||
spellCheck={false}
|
||||
onChange={event => setText(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <div id="archive-code-batch-error" role="alert" className="batch-error">{error}</div>}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.batch-body { display:flex; flex-direction:column; gap:8px; padding-top:4px; }
|
||||
textarea {
|
||||
display:block; width:100%; height:min(192px, 30vh); min-height:120px; resize:vertical;
|
||||
padding:10px 12px; border:1px solid #d4d4d8; border-radius:4px;
|
||||
background:#fff; color:#27272a; font-size:13px; line-height:20px; outline:none;
|
||||
}
|
||||
textarea:focus { border-color:#52525b; }
|
||||
textarea[aria-invalid=true] { border-color:#dc2626; }
|
||||
.batch-error { color:#dc2626; font-size:12px; line-height:1.6; }
|
||||
.batch-actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.batch-actions button {
|
||||
height:32px; padding:0 14px; border:1px solid #d4d4d8; border-radius:4px;
|
||||
background:#fff; color:#3f3f46; font-size:13px; line-height:20px; cursor:pointer;
|
||||
}
|
||||
.batch-actions .primary { background:#27272a; border-color:#27272a; color:#fff; }
|
||||
.batch-actions button:disabled { opacity:.45; cursor:not-allowed; }
|
||||
`}</style>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import { ProductArchiveCodeFieldFilter } from './ProductArchiveCodeFieldFilter'
|
||||
import { parseArchiveCodeQuery } from './archiveCodeQuery'
|
||||
|
||||
jest.mock('@/components/ui/Modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ title, children, footer }: { title: string; children: React.ReactNode; footer: React.ReactNode }) => (
|
||||
<div role="dialog" aria-label={title}>{children}{footer}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function setup(initial: ProductArchiveFilters = { supplier: ['供应商A'] }) {
|
||||
const changed = jest.fn()
|
||||
function Harness() {
|
||||
const [filters, setFilters] = useState(initial)
|
||||
return <ProductArchiveCodeFieldFilter field={{ key: 'spu', label: 'SPU' }} filters={filters} onChange={next => {
|
||||
changed(next)
|
||||
setFilters(next)
|
||||
}} />
|
||||
}
|
||||
const view = render(<Harness />)
|
||||
return { ...view, changed, input: screen.getByRole('textbox', { name: 'SPU筛选' }) }
|
||||
}
|
||||
|
||||
describe('archive code input and batch query', () => {
|
||||
beforeEach(() => jest.useFakeTimers())
|
||||
afterEach(() => jest.useRealTimers())
|
||||
|
||||
it('accepts Excel separators, preserves zero prefixes, and enforces the limit after dedupe', () => {
|
||||
expect(parseArchiveCodeQuery(' CJ0031\r\nCJ0250\tCJ0031\n\n001234 ')).toEqual({ values: ['CJ0031', 'CJ0250', '001234'], error: '' })
|
||||
const max = Array.from({ length: 5000 }, (_, i) => `CJ${i}`)
|
||||
expect(parseArchiveCodeQuery([...max, ...max].join('\n')).error).toBe('')
|
||||
expect(parseArchiveCodeQuery([...max, 'EXTRA'].join('\n')).error).toContain('当前 5001 个')
|
||||
expect(parseArchiveCodeQuery('A'.repeat(201)).error).toContain('200')
|
||||
})
|
||||
|
||||
it('opens only on Enter and applies pasted codes on the second Enter', () => {
|
||||
const { input, changed } = setup()
|
||||
fireEvent.click(input)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.change(input, { target: { value: 'CJ0031' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
const text = screen.getByRole('textbox', { name: 'SPU批量查询内容' })
|
||||
expect(text).toHaveValue('CJ0031')
|
||||
act(() => jest.advanceTimersByTime(1000))
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
fireEvent.change(text, { target: { value: 'CJ0031\r\nCJ0250\nCJ0031\n' } })
|
||||
fireEvent.keyDown(text, { key: 'Enter' })
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(changed).toHaveBeenLastCalledWith({ supplier: ['供应商A'], spu: ['CJ0031', 'CJ0250'] })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(input).toHaveValue('CJ0031, CJ0250')
|
||||
})
|
||||
|
||||
it('blocks oversize batches, allows Shift+Enter, and cancels without changing active filters', () => {
|
||||
const { input, changed } = setup({ spu: ['CJ0031'], supplier: ['供应商A'] })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
const text = screen.getByRole('textbox', { name: 'SPU批量查询内容' })
|
||||
fireEvent.change(text, { target: { value: Array.from({ length: 5001 }, (_, i) => `CJ${i}`).join('\n') } })
|
||||
fireEvent.keyDown(text, { key: 'Enter' })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('当前 5001 个')
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
fireEvent.change(text, { target: { value: 'CJ0250' } })
|
||||
fireEvent.keyDown(text, { key: 'Enter', shiftKey: true })
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
act(() => jest.advanceTimersByTime(1000))
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
expect(input).toHaveValue('CJ0031')
|
||||
})
|
||||
|
||||
it('debounces regular input, preserves Excel line separators on paste, and clears only this field', () => {
|
||||
const { input, changed } = setup()
|
||||
fireEvent.paste(input, { clipboardData: { getData: () => '001234\r\n005678' } })
|
||||
act(() => jest.advanceTimersByTime(600))
|
||||
expect(changed).toHaveBeenLastCalledWith({ supplier: ['供应商A'], spu: ['001234', '005678'] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空SPU筛选' }))
|
||||
expect(changed).toHaveBeenLastCalledWith({ supplier: ['供应商A'] })
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
|
||||
import type { ArchiveToolbarField } from './productArchiveToolbarFields'
|
||||
import { type ArchiveCodeField, parseArchiveCodeQuery } from './archiveCodeQuery'
|
||||
import { ProductArchiveCodeBatchDialog } from './ProductArchiveCodeBatchDialog'
|
||||
|
||||
export function ProductArchiveCodeFieldFilter({ field, filters, onChange }: {
|
||||
field: ArchiveToolbarField & { key: ArchiveCodeField }
|
||||
filters: ProductArchiveFilters
|
||||
onChange: (next: ProductArchiveFilters) => void
|
||||
}) {
|
||||
const appliedText = (filters[field.key] ?? []).join(', ')
|
||||
const [seenAppliedText, setSeenAppliedText] = useState(appliedText)
|
||||
const [draft, setDraft] = useState(appliedText)
|
||||
const [batchText, setBatchText] = useState<string | null>(null)
|
||||
const [composing, setComposing] = useState(false)
|
||||
const errorId = useId()
|
||||
const { values, error } = useMemo(() => parseArchiveCodeQuery(draft), [draft])
|
||||
|
||||
// 列头筛选、清除或快捷查询改变本字段时,同步普通输入框。
|
||||
if (seenAppliedText !== appliedText) {
|
||||
setSeenAppliedText(appliedText)
|
||||
setDraft(appliedText)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (batchText !== null || composing || error) return
|
||||
const current = filters[field.key] ?? []
|
||||
if (values.length === current.length && values.every((value, index) => value === current[index])) return
|
||||
const timer = window.setTimeout(() => {
|
||||
const next = { ...filters }
|
||||
if (values.length) next[field.key] = values
|
||||
else delete next[field.key]
|
||||
onChange(next)
|
||||
}, 600)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [batchText, composing, error, field.key, filters, onChange, values])
|
||||
|
||||
const apply = (codes: string[]) => {
|
||||
const next = { ...filters }
|
||||
if (codes.length) next[field.key] = codes
|
||||
else delete next[field.key]
|
||||
setDraft(codes.join(', '))
|
||||
setBatchText(null)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="code-field">
|
||||
<span className={`code-input ${values.length ? 'has-value' : ''}`}>
|
||||
<input
|
||||
aria-label={`${field.label}筛选`}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
placeholder={field.label}
|
||||
title={`输入${field.label},回车打开批量查询`}
|
||||
value={draft}
|
||||
spellCheck={false}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onPaste={event => {
|
||||
const text = event.clipboardData.getData('text')
|
||||
if (!/[\r\n\t]/.test(text)) return
|
||||
event.preventDefault()
|
||||
const input = event.currentTarget
|
||||
setDraft(draft.slice(0, input.selectionStart ?? 0)
|
||||
+ text.replace(/[\r\n\t]+/g, ', ')
|
||||
+ draft.slice(input.selectionEnd ?? draft.length))
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing && !composing) {
|
||||
event.preventDefault()
|
||||
setBatchText(parseArchiveCodeQuery(draft).values.join('\n'))
|
||||
}
|
||||
if (event.key === 'Escape') setDraft(appliedText)
|
||||
}}
|
||||
/>
|
||||
{draft && <button type="button" aria-label={`清空${field.label}筛选`} onClick={() => apply([])}><X size={12} /></button>}
|
||||
</span>
|
||||
{error && <span id={errorId} role="alert" className="code-error">{error}</span>}
|
||||
{batchText !== null && <ProductArchiveCodeBatchDialog
|
||||
label={field.label} initialText={batchText}
|
||||
onConfirm={apply} onCancel={() => setBatchText(null)}
|
||||
/>}
|
||||
<style jsx>{`
|
||||
.code-field { position:relative; display:inline-flex; }
|
||||
.code-input {
|
||||
display:inline-flex; align-items:center; gap:4px; width:140px; height:30px; padding:0 8px;
|
||||
border:1px solid #d4d4d8; border-radius:4px; background:#fff; color:#27272a;
|
||||
}
|
||||
.code-input:focus-within { border-color:#52525b; }
|
||||
.code-input.has-value { border-color:#2563eb; }
|
||||
input { flex:1; min-width:0; width:100%; height:100%; padding:0; border:0; outline:none; background:transparent; color:inherit; font-size:12px; }
|
||||
input::placeholder { color:#a1a1aa; }
|
||||
button { display:inline-flex; padding:0; border:0; background:transparent; color:#a1a1aa; cursor:pointer; }
|
||||
button:hover { color:#52525b; }
|
||||
.code-error { position:absolute; top:100%; left:0; z-index:2; width:240px; padding:4px 6px; background:#fff; color:#dc2626; font-size:12px; }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,8 @@ import {
|
||||
} from './productArchiveToolbarFields'
|
||||
import type { ArchiveSavedQuery } from './useProductArchiveToolbarPreferences'
|
||||
import { ProductArchiveOptionFieldFilter } from './ProductArchiveOptionFieldFilter'
|
||||
import { ProductArchiveCodeFieldFilter } from './ProductArchiveCodeFieldFilter'
|
||||
import { isArchiveCodeField } from './archiveCodeQuery'
|
||||
import { ProductArchiveQuickFieldFilter } from './ProductArchiveQuickFieldFilter'
|
||||
import { ProductArchiveFilterSettings } from './ProductArchiveFilterSettings'
|
||||
import { ProductArchiveSavedQueries } from './ProductArchiveSavedQueries'
|
||||
@ -17,8 +19,8 @@ const KEYWORD_COMMIT_DELAY_MS = 600
|
||||
|
||||
/**
|
||||
* 全量档案顶部筛选行(表头第一行,可收起):
|
||||
* 模糊搜索框(SPU/SKU/条码/商品名称/供应商)+ 常显点选字段框(点击弹可搜索勾选小弹窗,
|
||||
* 设置可配)+「请选择+请输入」+ 另存快捷查询;最右端边角是「设置 / 收起」。
|
||||
* 模糊搜索 + 编码直接输入(回车批量查询)+ 其他点选字段 + 快捷查询。
|
||||
* 设置可配显隐;最右端边角是「设置 / 收起」。
|
||||
*/
|
||||
export function ProductArchiveFilterBar({
|
||||
filters,
|
||||
@ -107,7 +109,9 @@ export function ProductArchiveFilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visiblePreciseFields.map((item) => (
|
||||
{visiblePreciseFields.map((item) => isArchiveCodeField(item.key) ? (
|
||||
<ProductArchiveCodeFieldFilter key={item.key} field={{ ...item, key: item.key }} filters={filters} onChange={onChange} />
|
||||
) : (
|
||||
<ProductArchiveOptionFieldFilter
|
||||
key={item.key}
|
||||
field={item}
|
||||
|
||||
@ -16,9 +16,27 @@ export function ProductArchiveSalesPeriodFilter({ filters, period, loading, onCh
|
||||
const setPeriod = (next?: string) => onChange({ ...filters, salesGranularity: granularity, salesPeriod: next || undefined })
|
||||
const setGranularity = (next: SalesGranularity) => onChange({ ...filters, salesGranularity: next, salesPeriod: undefined })
|
||||
const endYear = Number(period?.availableThrough?.slice(0, 4) || new Date().getFullYear())
|
||||
|
||||
// 详细口径(周期范围/同步时间/状态)收进悬停提示,工具栏只留一条短状态。
|
||||
const detailTip = [
|
||||
period ? `${period.start} 至 ${period.end}${period.partial ? '(截至已有数据)' : ''}` : null,
|
||||
period?.availableThrough ? `数据截至 ${period.availableThrough}` : null,
|
||||
period?.syncedAt ? `同步 ${new Date(period.syncedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : null,
|
||||
period?.lastAttemptStatus === 'FAILED' ? '最近同步失败' : null,
|
||||
period?.lastAttemptStatus === 'RUNNING' ? '同步中' : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
|
||||
const syncIssue = period?.lastAttemptStatus === 'FAILED' ? ' · 同步失败'
|
||||
: period?.lastAttemptStatus === 'RUNNING' ? ' · 同步中' : ''
|
||||
const statusText = loading ? '查询中...'
|
||||
: !period ? '等待同步'
|
||||
: !period.hasCoverage ? '该周期暂无数据'
|
||||
: `数据截至 ${(period.availableThrough ?? '').slice(5)}`
|
||||
const isWarning = !loading && (!period || !period.hasCoverage || period.lastAttemptStatus === 'FAILED')
|
||||
|
||||
return (
|
||||
<div className="sales-period" aria-label="销售统计周期">
|
||||
<span className="label"><CalendarDays size={15} />销售统计</span>
|
||||
<div className="sales-period" aria-label="销售统计周期" title={detailTip}>
|
||||
<span className="label" title="销售统计周期"><CalendarDays size={15} strokeWidth={1.8} /></span>
|
||||
<div className="tabs" role="group" aria-label="统计周期">
|
||||
{(['day', 'month', 'year'] as const).map((grain) => (
|
||||
<button key={grain} type="button" aria-pressed={granularity === grain}
|
||||
@ -38,33 +56,26 @@ export function ProductArchiveSalesPeriodFilter({ filters, period, loading, onCh
|
||||
onChange={(event) => setPeriod(event.target.value)} />
|
||||
)}
|
||||
<button className="latest" type="button" title="最新周期" aria-label="最新周期" onClick={() => setPeriod()}>
|
||||
<RotateCcw size={14} />
|
||||
<RotateCcw size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<span className="status" role="status">
|
||||
{loading ? '查询中...' : !period ? '等待同步' : !period.hasCoverage ? '该周期暂无源数据'
|
||||
: `${period.start} 至 ${period.end}${period.partial ? '(截至已有数据)' : ''}`}
|
||||
<span className={`status ${isWarning ? 'is-warning' : ''}`} role="status" title={detailTip}>
|
||||
{statusText}{syncIssue}
|
||||
</span>
|
||||
{period?.availableThrough && (
|
||||
<span className="sync">
|
||||
数据截至 {period.availableThrough}
|
||||
{period.syncedAt && ` · 同步 ${new Date(period.syncedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`}
|
||||
{period.lastAttemptStatus === 'FAILED' && ' · 最近同步失败'}
|
||||
{period.lastAttemptStatus === 'RUNNING' && ' · 同步中'}
|
||||
</span>
|
||||
)}
|
||||
<style jsx>{`
|
||||
/* 并入顶部操作行后的紧凑形态:不带自己的卡片底,作为行内一截。 */
|
||||
.sales-period { display:inline-flex; flex-wrap:wrap; align-items:center; gap:8px; min-width:0; }
|
||||
.label { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; color:#3f3f46; white-space:nowrap; }
|
||||
/* 紧凑形态:图标标签 + 分段粒度 + 窄选择器,详细口径在悬停提示里。 */
|
||||
.sales-period { display:inline-flex; flex-wrap:wrap; align-items:center; gap:6px; min-width:0; }
|
||||
.label { display:inline-flex; align-items:center; justify-content:center; width:26px; height:26px; border-radius:4px; color:#71717a; cursor:help; }
|
||||
.label:hover { background:#f4f4f5; color:#27272a; }
|
||||
.tabs { display:inline-flex; padding:2px; background:#f4f4f5; border-radius:5px; }
|
||||
.tabs button { width:36px; height:28px; padding:0; border:0; border-radius:4px; color:#52525b; background:transparent; cursor:pointer; }
|
||||
.tabs button { width:30px; height:26px; padding:0; border:0; border-radius:4px; color:#52525b; background:transparent; font-size:12px; cursor:pointer; }
|
||||
.tabs button[aria-pressed=true] { color:#fff; background:#27272a; }
|
||||
input,select { height:30px; width:144px; border:1px solid #d4d4d8; border-radius:4px; padding:0 7px; color:#27272a; background:#fff; font-size:12px; }
|
||||
select { width:94px; }
|
||||
.latest { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border:1px solid #d4d4d8; border-radius:4px; background:#fff; cursor:pointer; color:#52525b; }
|
||||
.status,.sync { font-size:12px; color:#71717a; overflow-wrap:anywhere; }
|
||||
.sync { margin-left:auto; }
|
||||
@media(max-width:900px) { .sync { margin-left:0; width:100%; } }
|
||||
input,select { height:28px; width:126px; border:1px solid #d4d4d8; border-radius:4px; padding:0 6px; color:#27272a; background:#fff; font-size:12px; }
|
||||
select { width:84px; }
|
||||
.latest { display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; border:1px solid #d4d4d8; border-radius:4px; background:#fff; cursor:pointer; color:#52525b; }
|
||||
.latest:hover { background:#f4f4f5; }
|
||||
.status { font-size:12px; color:#71717a; white-space:nowrap; cursor:help; }
|
||||
.status.is-warning { color:#b45309; }
|
||||
@media(max-width:900px) { .status { width:100%; } }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { splitArchiveFilterValues } from './productArchiveToolbarFields'
|
||||
|
||||
export const ARCHIVE_CODE_FIELDS = ['spu', 'sku', 'barcode'] as const
|
||||
export type ArchiveCodeField = (typeof ARCHIVE_CODE_FIELDS)[number]
|
||||
export const ARCHIVE_CODE_BATCH_LIMIT = 5000
|
||||
|
||||
export function isArchiveCodeField(key: string): key is ArchiveCodeField {
|
||||
return (ARCHIVE_CODE_FIELDS as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
export function parseArchiveCodeQuery(text: string) {
|
||||
const values = splitArchiveFilterValues(text)
|
||||
const error = values.length > ARCHIVE_CODE_BATCH_LIMIT
|
||||
? `一次最多查询 ${ARCHIVE_CODE_BATCH_LIMIT} 个编码,当前 ${values.length} 个,请减少后重试。`
|
||||
: values.some(value => value.length > 200)
|
||||
? '单个编码不能超过 200 个字符,请检查粘贴内容。'
|
||||
: ''
|
||||
return { values, error }
|
||||
}
|
||||
@ -7,10 +7,10 @@ import {
|
||||
} from './productArchiveToolbarFields'
|
||||
|
||||
describe('product archive toolbar fields', () => {
|
||||
it('puts SPU/barcode/supplier first in the default visible fields', () => {
|
||||
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS.slice(0, 3)).toEqual(['spu', 'barcode', 'supplier'])
|
||||
it('puts code inputs before the existing supplier and owner filters', () => {
|
||||
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS.slice(0, 4)).toEqual(['spu', 'sku', 'barcode', 'supplier'])
|
||||
expect(DEFAULT_ARCHIVE_PRECISE_FIELDS).toEqual([
|
||||
'spu', 'barcode', 'supplier',
|
||||
'spu', 'sku', 'barcode', 'supplier',
|
||||
'designer', 'opsHead', 'opsDeptHead', 'opsDirector', 'purchaser', 'warehouseHead',
|
||||
'categoryL1', 'brand', 'productStatus', 'ownerEmploymentStatus',
|
||||
])
|
||||
@ -23,16 +23,15 @@ describe('product archive toolbar fields', () => {
|
||||
expect(designer?.staticOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('covers all quick-field keys and keeps sku/productName out of the precise pool', () => {
|
||||
it('keeps code inputs configurable without duplicating quick-field keys', () => {
|
||||
const preciseKeys = ARCHIVE_PRECISE_FIELDS.map((item) => item.key)
|
||||
for (const key of ['sku', 'productName']) {
|
||||
expect(preciseKeys).not.toContain(key)
|
||||
}
|
||||
expect(preciseKeys).not.toContain('productName')
|
||||
// SPU/条码/供应商既在点选框池也在「请选择」池
|
||||
for (const key of ['spu', 'barcode', 'supplier']) {
|
||||
for (const key of ['spu', 'sku', 'barcode', 'supplier']) {
|
||||
expect(preciseKeys).toContain(key)
|
||||
expect(ARCHIVE_QUICK_FIELD_OPTIONS.some((item) => item.key === key)).toBe(true)
|
||||
}
|
||||
expect(new Set(ARCHIVE_QUICK_FIELD_OPTIONS.map(item => item.key)).size).toBe(ARCHIVE_QUICK_FIELD_OPTIONS.length)
|
||||
})
|
||||
|
||||
it('normalizes precise fields by dedupe and pool membership', () => {
|
||||
|
||||
@ -16,11 +16,12 @@ function field(key: string, label: string, staticOptions?: readonly string[]): A
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部常显字段池(设置里可配显隐)。统一形态:点击弹「可搜索 + 可勾选」小弹窗,
|
||||
* 字段名内置在框占位里。模糊搜索框已覆盖 SPU/SKU/条码/商品名称/供应商,不再放外框。
|
||||
* 顶部常显字段池(设置里可配显隐)。编码字段直接输入、回车批量查询;
|
||||
* 其余字段点击弹可搜索的勾选面板,字段名内置在框占位里。
|
||||
*/
|
||||
export const ARCHIVE_PRECISE_FIELDS: ArchiveToolbarField[] = [
|
||||
field('spu', 'SPU'),
|
||||
field('sku', 'SKU'),
|
||||
field('barcode', '条码'),
|
||||
field('supplier', '供应商'),
|
||||
field('designer', '设计师'),
|
||||
@ -48,16 +49,15 @@ export const ARCHIVE_PRECISE_FIELDS: ArchiveToolbarField[] = [
|
||||
field('installDocId', '安装图纸/视频ID'),
|
||||
]
|
||||
|
||||
/** 默认常显框(SPU/条码/供应商置顶 + 运营链字段 + 高频点选框)。 */
|
||||
/** 默认常显框(三个编码输入框置顶 + 供应商与运营链点选框)。 */
|
||||
export const DEFAULT_ARCHIVE_PRECISE_FIELDS: string[] = [
|
||||
'spu', 'barcode', 'supplier',
|
||||
'spu', 'sku', 'barcode', 'supplier',
|
||||
'designer', 'opsHead', 'opsDeptHead', 'opsDirector', 'purchaser', 'warehouseHead',
|
||||
'categoryL1', 'brand', 'productStatus', 'ownerEmploymentStatus',
|
||||
]
|
||||
|
||||
/** 「请选择+请输入」的字段池:全部可精准匹配的字段。 */
|
||||
export const ARCHIVE_QUICK_FIELD_OPTIONS: ArchiveToolbarField[] = [
|
||||
field('sku', 'SKU'),
|
||||
field('productName', '商品名称'),
|
||||
...ARCHIVE_PRECISE_FIELDS,
|
||||
]
|
||||
|
||||
@ -23,12 +23,20 @@ describe('product archive toolbar preferences', () => {
|
||||
)
|
||||
|
||||
// spu 在字段框池(置顶点选框)被保留;nope 被清掉;quickSelects 已废弃被忽略
|
||||
expect(preferences.preciseFields).toEqual(['designer', 'spu', 'region'])
|
||||
expect(preferences.preciseFields).toEqual(['designer', 'spu', 'sku', 'region'])
|
||||
expect(preferences.collapsed).toBe(false)
|
||||
expect(preferences.savedQueries).toEqual([])
|
||||
expect('quickSelects' in preferences).toBe(false)
|
||||
})
|
||||
|
||||
it('only adds the SKU input once and preserves an intentionally hidden input', () => {
|
||||
const old = parseProductArchiveToolbarPreferences(JSON.stringify({ preciseFields: ['spu', 'barcode', 'brand'] }))
|
||||
expect(old.preciseFields).toEqual(['spu', 'sku', 'barcode', 'brand'])
|
||||
const hidden = parseProductArchiveToolbarPreferences(JSON.stringify({ ...old, preciseFields: ['spu', 'brand'] }))
|
||||
expect(hidden.preciseFields).toEqual(['spu', 'brand'])
|
||||
expect(parseProductArchiveToolbarPreferences(JSON.stringify({ preciseFields: [] })).preciseFields).toEqual([])
|
||||
})
|
||||
|
||||
it('drops saved queries without usable filters and caps the list', () => {
|
||||
const queries = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `q-${index}`,
|
||||
|
||||
@ -19,6 +19,7 @@ export type ArchiveSavedQuery = {
|
||||
}
|
||||
|
||||
export type ProductArchiveToolbarPreferences = {
|
||||
codeInputVersion: 1
|
||||
/** 顶部常显的字段框(设置里可配,默认运营链字段+四个高频点选框)。 */
|
||||
preciseFields: string[]
|
||||
/** 顶部筛选行是否收起。 */
|
||||
@ -28,6 +29,7 @@ export type ProductArchiveToolbarPreferences = {
|
||||
}
|
||||
|
||||
export const DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES: ProductArchiveToolbarPreferences = {
|
||||
codeInputVersion: 1,
|
||||
preciseFields: [...DEFAULT_ARCHIVE_PRECISE_FIELDS],
|
||||
collapsed: false,
|
||||
savedQueries: [],
|
||||
@ -63,9 +65,15 @@ export function parseProductArchiveToolbarPreferences(raw: string | null): Produ
|
||||
if (!raw) return DEFAULT_PRODUCT_ARCHIVE_TOOLBAR_PREFERENCES
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const preciseFields = normalizeArchivePreciseFields(parsed.preciseFields)
|
||||
// 旧设置仅升级一次,补上独立 SKU 输入框;之后用户隐藏 SKU 的设置照常保留。
|
||||
if (parsed.codeInputVersion !== 1 && preciseFields.length && !preciseFields.includes('sku')) {
|
||||
preciseFields.splice(Math.max(0, preciseFields.indexOf('spu') + 1), 0, 'sku')
|
||||
}
|
||||
return {
|
||||
codeInputVersion: 1,
|
||||
// 全部取消勾选是合法状态(顶部一个字段框都不摆),空数组保持空。
|
||||
preciseFields: normalizeArchivePreciseFields(parsed.preciseFields),
|
||||
preciseFields,
|
||||
collapsed: parsed.collapsed === true,
|
||||
savedQueries: sanitizeSavedQueries(parsed.savedQueries),
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ interface ModalProps {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
size?: 'small' | 'medium' | 'large' | 'xlarge'
|
||||
/** 紧凑查询等短操作使用轻遮罩和较小留白;默认弹窗样式保持原有口径。 */
|
||||
variant?: 'default' | 'compact'
|
||||
className?: string
|
||||
bodyClassName?: string
|
||||
showCloseButton?: boolean
|
||||
@ -36,6 +38,7 @@ export default function Modal({
|
||||
title,
|
||||
children,
|
||||
size = 'medium',
|
||||
variant = 'default',
|
||||
className,
|
||||
bodyClassName,
|
||||
showCloseButton = true,
|
||||
@ -136,8 +139,9 @@ export default function Modal({
|
||||
|
||||
if (!shouldRender) return null
|
||||
|
||||
const compact = variant === 'compact'
|
||||
const sizeClasses = {
|
||||
small: 'max-w-md',
|
||||
small: compact ? 'max-w-[400px]' : 'max-w-md',
|
||||
medium: 'max-w-2xl',
|
||||
large: 'max-w-4xl',
|
||||
xlarge: 'max-w-6xl',
|
||||
@ -152,9 +156,9 @@ export default function Modal({
|
||||
data-testid="obsidian-modal-overlay"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
|
||||
style={{
|
||||
backgroundColor: isAnimating ? 'rgba(0, 0, 0, 0.45)' : 'rgba(0, 0, 0, 0)',
|
||||
backdropFilter: isAnimating ? 'blur(32px)' : 'blur(0px)',
|
||||
WebkitBackdropFilter: isAnimating ? 'blur(32px)' : 'blur(0px)',
|
||||
backgroundColor: isAnimating ? (compact ? 'rgba(24, 24, 27, 0.16)' : 'rgba(0, 0, 0, 0.45)') : 'rgba(0, 0, 0, 0)',
|
||||
backdropFilter: compact ? 'none' : isAnimating ? 'blur(32px)' : 'blur(0px)',
|
||||
WebkitBackdropFilter: compact ? 'none' : isAnimating ? 'blur(32px)' : 'blur(0px)',
|
||||
transition: `all ${enterDuration} ${appleAnimationTokens.components.modal.enter.easing}`,
|
||||
}}
|
||||
onClick={closeOnOverlayClick ? onClose : undefined}
|
||||
@ -171,7 +175,7 @@ export default function Modal({
|
||||
maxHeight: 'min(92vh, 92dvh)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: isAnimating ? '0 40px 80px rgba(0, 0, 0, 0.30)' : 'none',
|
||||
boxShadow: isAnimating ? (compact ? '0 12px 32px rgba(24, 24, 27, 0.14)' : '0 40px 80px rgba(0, 0, 0, 0.30)') : 'none',
|
||||
transform: isAnimating ? 'scale(1) translateY(0)' : 'scale(0.98) translateY(10px)',
|
||||
opacity: isAnimating ? 1 : 0,
|
||||
transition: `all ${enterDuration} cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
@ -179,12 +183,12 @@ export default function Modal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!hideHeader && (title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-4 py-4 sm:px-8 sm:py-6">
|
||||
<div className={`flex items-center justify-between ${compact ? 'px-5 pt-5' : 'border-b border-[var(--obs-shell-border)] px-4 py-4 sm:px-8 sm:py-6'}`}>
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-xl font-black tracking-tight text-[var(--obs-shell-title)]"
|
||||
className={`${compact ? 'text-base font-semibold leading-6' : 'text-xl font-black tracking-tight'} text-[var(--obs-shell-title)]`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
@ -203,7 +207,7 @@ export default function Modal({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`obsidian-modal-body flex-1 overflow-y-auto px-4 py-4 sm:px-8 sm:py-8 text-[var(--obs-shell-text)] ${bodyClassName ?? ''}`}
|
||||
className={`obsidian-modal-body flex-1 overflow-y-auto ${compact ? 'px-5 pt-2 pb-3' : 'px-4 py-4 sm:px-8 sm:py-8'} text-[var(--obs-shell-text)] ${bodyClassName ?? ''}`}
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--obs-shell-border) transparent',
|
||||
@ -213,7 +217,7 @@ export default function Modal({
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-4 py-4 sm:px-8 sm:py-6">
|
||||
<div className={compact ? 'px-5 pb-5' : 'border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-4 py-4 sm:px-8 sm:py-6'}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user