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:
Drsakura
2026-10-22 15:23:10 +08:00
co-authored by Claude Opus 5
parent f32a2daaef
commit 226949fa53
3 changed files with 402 additions and 0 deletions
+3
View File
@@ -4,6 +4,7 @@ import LoginPage from './pages/LoginPage'
import Settings from './components/Settings'
import GlobalHeader from './components/GlobalHeader'
import AIAssistant from './components/AIAssistant'
import QuickLookup from './components/QuickLookup'
import { readPreference } from './preferences'
import { AuthProvider, useAuth, hasPermission } from './auth'
import { loadBranding, useBranding, setPageTitle } from './branding'
@@ -348,6 +349,8 @@ function Shell({ theme, onThemeChange }) {
)}
<AIAssistant isOpen={assistantOpen} onClose={closeAssistant} />
{/* 产品速查(Ctrl/⌘+J):自己管开关,挂在外壳里就能在任何页面唤出 */}
<QuickLookup />
<Settings
isOpen={settingsOpen}
onClose={closeSettings}
+187
View File
@@ -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
+212
View File
@@ -1510,3 +1510,215 @@ body { -webkit-font-smoothing: auto; }
.cleanup-toolbar { justify-content: flex-start; }
.cleanup-toolbar-right { width: 100%; }
}
/* ---------------------------- 产品速查(Ctrl/⌘+J) ----------------------------
报价报到一半想确认一下某个货的采购价/起订量/箱规时用。只读、不跳转,
Esc 就回到刚才的地方 —— 所以它是浮层而不是一个页面。 */
.ql-backdrop {
position: fixed; inset: 0; z-index: 1200;
display: flex; align-items: flex-start; justify-content: center;
padding: 10vh 16px 24px;
background: rgba(6, 12, 20, 0.55);
backdrop-filter: blur(2px);
}
.ql-panel {
width: min(760px, 100%);
max-height: 76vh;
display: flex; flex-direction: column;
border: 1px solid var(--border-light);
border-radius: var(--radius-lg, 12px);
background: var(--surface-base, #fff);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.ql-head {
display: flex; align-items: center; gap: 10px;
padding: 13px 14px;
border-bottom: 1px solid var(--border-light);
background: var(--surface-deep);
}
.ql-head svg { flex: none; color: var(--text-tertiary); }
/* 16px 是底线:再小 iOS 聚焦时会把整页放大 */
.ql-head input {
flex: 1; min-width: 0;
border: none; background: transparent; outline: none;
color: var(--text-primary); font: inherit; font-size: 16px;
}
.ql-head kbd {
flex: none; padding: 2px 7px;
border: 1px solid var(--border-light); border-radius: 5px;
color: var(--text-tertiary); font-size: 11.5px; font-family: inherit;
}
.ql-close {
flex: none; display: inline-flex; padding: 4px;
border: none; background: transparent; color: var(--text-tertiary); cursor: pointer;
}
.ql-close:hover { color: var(--text-primary); }
.ql-body { overflow-y: auto; padding: 6px; }
.ql-hint {
padding: 22px 16px; text-align: center;
color: var(--text-tertiary); font-size: 13.5px; line-height: 1.8;
}
.ql-error { color: var(--danger, #c0392b); }
.ql-row {
display: flex; align-items: flex-start; gap: 14px;
padding: 11px 12px;
border-radius: var(--radius-md, 8px);
}
.ql-row:hover { background: var(--surface-hover); }
.ql-row + .ql-row { border-top: 1px solid var(--border-light); }
.ql-main { flex: 1; min-width: 0; }
.ql-sku { display: flex; align-items: center; gap: 7px; }
/* 货号和价格都做成按钮:报价时最常做的动作就是把它们抄到别处 */
.ql-copy-sku {
position: relative;
border: none; background: transparent; padding: 0; cursor: pointer;
color: var(--text-primary); font: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 14.5px; font-weight: 600;
}
.ql-copy-sku:hover { color: var(--primary, #2f6fed); text-decoration: underline; }
.ql-flag {
padding: 1px 6px; border-radius: 4px;
background: var(--warning-bg, #fdf3e3); color: var(--warning, #a9730d);
font-size: 11px;
}
.ql-name { margin-top: 3px; color: var(--text-primary); font-size: 13.5px; }
.ql-sub { margin-top: 2px; color: var(--text-tertiary); font-size: 12.5px; }
.ql-numbers { flex: none; width: 236px; text-align: right; }
.ql-price {
position: relative;
border: none; background: transparent; padding: 0; cursor: pointer;
color: var(--primary, #2f6fed); font: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 16px; font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ql-price:hover { text-decoration: underline; }
.ql-meta {
display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap;
margin-top: 3px; color: var(--text-tertiary); font-size: 12.5px;
}
.ql-packing { color: var(--text-secondary); }
.ql-tiers {
display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap;
margin-top: 4px; font-size: 12px;
font-family: var(--font-mono, ui-monospace, monospace);
color: var(--text-secondary);
}
/* 复制成功的角标:贴在被点的那个数字右上角,不挤动布局 */
.ql-copied {
position: absolute; left: 100%; top: -2px; margin-left: 6px;
padding: 1px 6px; border-radius: 4px; white-space: nowrap;
background: var(--success-bg, #e6f4ea); color: var(--success, #1e7e34);
font-size: 11px; font-weight: 400; font-family: inherit;
}
.ql-foot {
padding: 9px 14px;
border-top: 1px solid var(--border-light);
background: var(--surface-deep);
color: var(--text-tertiary); font-size: 12.5px; text-align: center;
}
@media (max-width: 760px) {
.ql-backdrop { padding: 6vh 10px 16px; }
.ql-row { flex-wrap: wrap; gap: 6px; }
.ql-numbers { width: 100%; text-align: left; }
.ql-meta, .ql-tiers { justify-content: flex-start; }
.ql-copied { position: static; margin-left: 6px; }
}
/* ---------------------------- 订单支出抽屉 ---------------------------- */
.oe-hint { padding: 20px 0; color: var(--text-tertiary); font-size: 13.5px; }
.oe-error {
margin: 0 0 12px; padding: 9px 12px;
border: 1px solid var(--danger-border, var(--border-light)); border-radius: var(--radius-md, 8px);
background: var(--danger-bg, #fdecea); color: var(--danger, #c0392b); font-size: 13px;
}
.oe-summary { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.oe-stat {
flex: 1; min-width: 150px;
padding: 11px 13px;
border: 1px solid var(--border-light); border-radius: var(--radius-md, 8px);
background: var(--surface-deep);
}
.oe-stat span { display: block; color: var(--text-tertiary); font-size: 12.5px; }
.oe-stat strong {
display: block; margin-top: 3px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 18px; font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.oe-stat .oe-unpaid { color: var(--warning, #a9730d); }
/* 这一行必须留着:减掉的只是杂费,没减货款采购成本。
不写清楚会被当成真毛利拿去报价。 */
.oe-stat em { display: block; margin-top: 2px; color: var(--text-tertiary); font-size: 11.5px; font-style: normal; }
.oe-bycat { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.oe-bycat span {
padding: 3px 9px; border: 1px solid var(--border-light); border-radius: 20px;
background: var(--surface-base); color: var(--text-secondary); font-size: 12.5px;
}
.oe-bycat b { font-family: var(--font-mono, ui-monospace, monospace); }
.oe-form {
margin-bottom: 16px; padding: 13px;
border: 1px solid var(--border-light); border-radius: var(--radius-md, 8px);
background: var(--surface-deep);
}
.oe-form-title { margin-bottom: 10px; font-size: 13.5px; font-weight: 600; color: var(--text-primary); }
.oe-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(146px, 1fr)); gap: 9px 10px; }
.oe-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; color: var(--text-tertiary); }
.oe-grid input, .oe-grid select {
padding: 7px 9px;
border: 1px solid var(--border-light); border-radius: var(--radius-sm, 6px);
background: var(--surface-base); color: var(--text-primary);
font: inherit; font-size: 13.5px;
}
.oe-grid .oe-wide { grid-column: 1 / -1; }
/* 跨币种时才出现的汇率格,给个底色提示这是必填的 */
.oe-grid .oe-rate input { border-color: var(--primary-border, var(--border-light)); background: var(--primary-bg, var(--surface-base)); }
.oe-form-foot { display: flex; align-items: center; gap: 9px; margin-top: 11px; }
.oe-grow { flex: 1; }
.oe-check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
.oe-table { margin-top: 4px; }
.oe-table .oe-num { text-align: right; font-family: var(--font-mono, ui-monospace, monospace); font-variant-numeric: tabular-nums; }
.oe-table tr.oe-editing > td { background: var(--primary-bg, var(--surface-hover)); }
.oe-voucher { margin-top: 2px; color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono, ui-monospace, monospace); }
.oe-note { margin-top: 2px; color: var(--text-tertiary); font-size: 12px; }
.oe-ops { white-space: nowrap; }
.oe-del { color: var(--danger, #c0392b); }
.oe-paid {
padding: 2px 9px; border-radius: 20px; cursor: pointer;
border: 1px solid var(--warning-border, var(--border-light));
background: var(--warning-bg, #fdf3e3); color: var(--warning, #a9730d);
font: inherit; font-size: 12px;
}
.oe-paid.is-paid {
border-color: var(--success-border, var(--border-light));
background: var(--success-bg, #e6f4ea); color: var(--success, #1e7e34);
}
.oe-paid:disabled { cursor: default; }
/* 订单列表里的杂费列 */
.order-expense-unpaid { color: var(--warning, #a9730d); font-style: normal; font-size: 12px; }
@media (max-width: 760px) {
.oe-summary { flex-direction: column; }
.oe-grid { grid-template-columns: 1fr 1fr; }
}