feat(sku): 产品速查浮层(Ctrl/⌘+J)
报价报到一半想确认某个货的采购价、起订量、箱规时用。跟已有的两个东西都 不是一件事: ⌘K 全局搜索 是「找到后跳过去」—— 一跳走,正在写的报价就断了 SkuPicker 是「挑一个塞进表单」—— 要点产品组再点货号,两层 速查 是「就地看数」—— 不跳转、不改任何东西,Esc 回到刚才的地方 · 新接口 GET /api/sku/quick:一次把采购价、起订量、供应商、规格、箱规、 阶梯价全带回来,不返回 path(它不是用来跳转的)。 · 货号精确/前缀命中排最前。报价时多半直接敲货号,让它跟在一堆品名模糊 匹配后面就等于没做速查。 · 箱规拼成「40×30×25cm / 12.5kg / 装 24」一句话 —— 报价要的是这一句, 不是四个分开的数字。 · 点货号或价格可复制;clipboard API 在非 HTTPS 内网下不可用,退回 execCommand。 · 没占 ⌘K,因为那已经是全局搜索了,抢掉会打断老习惯。 · 页脚写死「价格是采购价,不是报价」防手滑。 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f32a2daaef
commit
226949fa53
@@ -0,0 +1,187 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { SearchIcon, CloseIcon } from './Icons'
|
||||
|
||||
/**
|
||||
* 产品速查。
|
||||
*
|
||||
* 跟 ⌘K 全局搜索和 SkuPicker 都不是一件事:
|
||||
* · ⌘K 是「找到后跳过去」—— 一跳走,正在写的报价就断了;
|
||||
* · SkuPicker 是「挑一个塞进表单」—— 要点产品组再点货号,两层;
|
||||
* · 速查是「报价报到一半,想起来要确认一下这个货多少钱、起订多少、一箱装几个」。
|
||||
* 所以它不跳转、不改任何东西,就是把数摆出来,还能一键复制。
|
||||
*
|
||||
* 快捷键 Ctrl/⌘ + J。没占 K 是因为 ⌘K 已经是全局搜索了,抢掉会把老习惯打断。
|
||||
*/
|
||||
|
||||
const money = (value, currency) => {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return '—'
|
||||
return `${currency || ''} ${num.toFixed(2)}`.trim()
|
||||
}
|
||||
|
||||
function QuickLookup() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [copied, setCopied] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
/* 开关:Ctrl/⌘+J 开,Esc 关。开的时候自动聚焦并全选,
|
||||
连着查第二个货号时不用先手动删掉上一个。 */
|
||||
useEffect(() => {
|
||||
const onKeyDown = event => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'j') {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
window.setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = event => { if (event.key === 'Escape') setOpen(false) }
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open])
|
||||
|
||||
/* 查询:180ms 防抖,跟全局搜索保持同一个手感 */
|
||||
useEffect(() => {
|
||||
const term = keyword.trim()
|
||||
if (!term) { setResults([]); setError(''); return }
|
||||
let cancelled = false
|
||||
const timer = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await axios.get('/api/sku/quick', { params: { q: term } })
|
||||
if (!cancelled) setResults(data.results || [])
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setResults([])
|
||||
setError(err?.response?.data?.error || '查不动了,确认一下后端还在不在')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}, 180)
|
||||
return () => { cancelled = true; window.clearTimeout(timer) }
|
||||
}, [keyword])
|
||||
|
||||
/* 复制:报价时最常做的动作就是把货号或价格抄到别处去。
|
||||
navigator.clipboard 在非 HTTPS 的内网环境下可能不可用,退回 execCommand。 */
|
||||
const copy = useCallback(async (text, tag) => {
|
||||
const value = String(text ?? '')
|
||||
if (!value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const helper = document.createElement('textarea')
|
||||
helper.value = value
|
||||
helper.style.position = 'fixed'
|
||||
helper.style.opacity = '0'
|
||||
document.body.appendChild(helper)
|
||||
helper.select()
|
||||
try { document.execCommand('copy') } catch { /* 复制不了就算了,不弹错 */ }
|
||||
helper.remove()
|
||||
}
|
||||
setCopied(tag)
|
||||
window.setTimeout(() => setCopied(current => (current === tag ? '' : current)), 1200)
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ql-backdrop" onMouseDown={event => { if (event.target === event.currentTarget) setOpen(false) }}>
|
||||
<div className="ql-panel" role="dialog" aria-label="产品速查">
|
||||
<div className="ql-head">
|
||||
<SearchIcon size={17} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
placeholder="货号 / 品名 / 规格 —— 报价时查采购价与起订量"
|
||||
autoComplete="off"
|
||||
aria-label="产品速查关键词"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
<button type="button" className="ql-close" onClick={() => setOpen(false)} aria-label="关闭"><CloseIcon size={15} /></button>
|
||||
</div>
|
||||
|
||||
<div className="ql-body">
|
||||
{!keyword.trim() && (
|
||||
<div className="ql-hint">
|
||||
敲货号直接命中;敲品名按采购价从低到高排。<br />
|
||||
这里<strong>只看不改</strong>,随时 Esc 回到刚才的地方。
|
||||
</div>
|
||||
)}
|
||||
{loading && <div className="ql-hint">正在查…</div>}
|
||||
{error && <div className="ql-hint ql-error">{error}</div>}
|
||||
{!loading && !error && keyword.trim() && !results.length && (
|
||||
<div className="ql-hint">没找到。换个关键词,或者确认这个货号的合同导入了没。</div>
|
||||
)}
|
||||
|
||||
{results.map(item => (
|
||||
<div className="ql-row" key={item.sku}>
|
||||
<div className="ql-main">
|
||||
<div className="ql-sku">
|
||||
<button type="button" className="ql-copy-sku" title="复制货号" onClick={() => copy(item.displaySku, `sku-${item.sku}`)}>
|
||||
{item.displaySku}
|
||||
{copied === `sku-${item.sku}` && <span className="ql-copied">已复制</span>}
|
||||
</button>
|
||||
{item.confidence === 'low' && <span className="ql-flag">待核对</span>}
|
||||
</div>
|
||||
<div className="ql-name">{item.name || '—'}</div>
|
||||
{item.nameEn && <div className="ql-sub">{item.nameEn}</div>}
|
||||
{item.spec && <div className="ql-sub">规格 {item.spec}</div>}
|
||||
</div>
|
||||
|
||||
<div className="ql-numbers">
|
||||
<button type="button" className="ql-price" title="复制价格" onClick={() => copy(item.price ?? '', `price-${item.sku}`)}>
|
||||
{money(item.price, item.currency)}
|
||||
{copied === `price-${item.sku}` && <span className="ql-copied">已复制</span>}
|
||||
</button>
|
||||
<div className="ql-meta">
|
||||
<span>起订 {item.moq ?? '—'}</span>
|
||||
<span>{item.supplier || '无供应商'}</span>
|
||||
</div>
|
||||
{item.packing && (
|
||||
<div className="ql-meta ql-packing">
|
||||
{item.packing.cartonQty ? <span>装箱 {item.packing.cartonQty}</span> : null}
|
||||
{item.packing.cartonSize ? <span>{item.packing.cartonSize}</span> : null}
|
||||
{item.packing.cartonWeight ? <span>{item.packing.cartonWeight}kg</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{!!item.tiers?.length && (
|
||||
<div className="ql-tiers">
|
||||
{item.tiers.map((tier, index) => (
|
||||
<span key={index}>{tier.moq_from}{tier.moq_to ? `-${tier.moq_to}` : '+'} → {money(tier.price, tier.currency)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!results.length && (
|
||||
<div className="ql-foot">
|
||||
{results.length} 条 · 点货号或价格可复制 · 价格是<strong>采购价</strong>,不是报价
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuickLookup
|
||||
Reference in New Issue
Block a user