diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d1c2252..da733f9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 }) { )} + {/* 产品速查(Ctrl/⌘+J):自己管开关,挂在外壳里就能在任何页面唤出 */} + { + 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 ( +
{ if (event.target === event.currentTarget) setOpen(false) }}> +
+
+ + setKeyword(event.target.value)} + placeholder="货号 / 品名 / 规格 —— 报价时查采购价与起订量" + autoComplete="off" + aria-label="产品速查关键词" + /> + Esc + +
+ +
+ {!keyword.trim() && ( +
+ 敲货号直接命中;敲品名按采购价从低到高排。
+ 这里只看不改,随时 Esc 回到刚才的地方。 +
+ )} + {loading &&
正在查…
} + {error &&
{error}
} + {!loading && !error && keyword.trim() && !results.length && ( +
没找到。换个关键词,或者确认这个货号的合同导入了没。
+ )} + + {results.map(item => ( +
+
+
+ + {item.confidence === 'low' && 待核对} +
+
{item.name || '—'}
+ {item.nameEn &&
{item.nameEn}
} + {item.spec &&
规格 {item.spec}
} +
+ +
+ +
+ 起订 {item.moq ?? '—'} + {item.supplier || '无供应商'} +
+ {item.packing && ( +
+ {item.packing.cartonQty ? 装箱 {item.packing.cartonQty} : null} + {item.packing.cartonSize ? {item.packing.cartonSize} : null} + {item.packing.cartonWeight ? {item.packing.cartonWeight}kg : null} +
+ )} + {!!item.tiers?.length && ( +
+ {item.tiers.map((tier, index) => ( + {tier.moq_from}{tier.moq_to ? `-${tier.moq_to}` : '+'} → {money(tier.price, tier.currency)} + ))} +
+ )} +
+
+ ))} +
+ + {!!results.length && ( +
+ {results.length} 条 · 点货号或价格可复制 · 价格是采购价,不是报价 +
+ )} +
+
+ ) +} + +export default QuickLookup diff --git a/frontend/src/styles/stage1.css b/frontend/src/styles/stage1.css index 5d39c46..bab20f2 100644 --- a/frontend/src/styles/stage1.css +++ b/frontend/src/styles/stage1.css @@ -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; } +}