本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、 SKU 目录等模块,以及前端相应页面与样式改版。 后端 - 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定 - 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志 - 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等) - 新增合同解析、合同文件保留策略、客户归属与访问控制 - 新增 SKU 目录、组织品牌、新客开发、系统日志等路由 前端 - 新增登录页与用户 / 权限组 / 系统日志管理页 - 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心 - 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件 - 新增英文大写金额、未保存改动拦截、抽屉与骨架屏 - 新增 stage1 样式层;产品、供应商、订单等页面改版 - EmbeddedSku 页面并入现有产品体系后删除 开源准备 - 新增 AGPL-3.0 LICENSE - README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、 补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0 - 移除历史遗留的空文件 _index_backup.txt - 文档中的项目路径更新为 happy-export
261 lines
8.8 KiB
React
261 lines
8.8 KiB
React
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { SearchIcon, ChevronDownIcon, CloseIcon } from './Icons'
|
||
|
||
/**
|
||
* 可搜索下拉(combobox)
|
||
*
|
||
* 供应商 272 家、产品 300+ 个,原生 <select> 只能一格一格滚。这里把触发区做成
|
||
* 一个搜索框:点开就能打字,候选实时收窄,↑↓ 选择、回车确认、Esc 收起。
|
||
*
|
||
* 实现要点:
|
||
* - 菜单用 createPortal 挂到 body + position:fixed —— 抽屉的 .drawer-body 是
|
||
* overflow:auto,就地绝对定位会被它裁掉;fixed 还能跟着滚动/缩放重新定位。
|
||
* - 触发区沿用全局「无方框」输入框规则:靠底色区分,不做描边与聚焦光环。
|
||
* - 展开时按实时位置决定朝上还是朝下弹,空间不够就压 max-height 自身滚动。
|
||
*
|
||
* props
|
||
* value / onChange(value, option)
|
||
* options [{ value, label, hint? }],hint 显示在右侧(灰)
|
||
* placeholder 未选中时的提示
|
||
* searchPlaceholder 展开后的提示
|
||
* emptyText 无匹配时的文案
|
||
* size 'md'(38px) | 'sm'(32px)
|
||
* clearable 选中后是否给出清除按钮
|
||
*/
|
||
|
||
const MENU_MAX = 268
|
||
const GAP = 6
|
||
|
||
function SearchSelect({
|
||
value,
|
||
onChange,
|
||
options = [],
|
||
placeholder = '请选择',
|
||
searchPlaceholder = '输入关键词搜索…',
|
||
emptyText = '没有匹配项',
|
||
size = 'md',
|
||
disabled = false,
|
||
clearable = false,
|
||
style,
|
||
className = ''
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [query, setQuery] = useState('')
|
||
const [active, setActive] = useState(0)
|
||
const [pos, setPos] = useState(null)
|
||
|
||
const rootRef = useRef(null)
|
||
const menuRef = useRef(null)
|
||
const inputRef = useRef(null)
|
||
const listRef = useRef(null)
|
||
|
||
const selected = useMemo(
|
||
() => options.find((o) => String(o.value) === String(value ?? '')) || null,
|
||
[options, value]
|
||
)
|
||
|
||
const filtered = useMemo(() => {
|
||
const kw = query.trim().toLowerCase()
|
||
if (!kw) return options
|
||
return options.filter((o) => `${o.label || ''} ${o.hint || ''}`.toLowerCase().includes(kw))
|
||
}, [options, query])
|
||
|
||
/* 按触发区的实时位置定位菜单:下方空间不够就翻到上面 */
|
||
const place = useCallback(() => {
|
||
const el = rootRef.current
|
||
if (!el) return
|
||
const r = el.getBoundingClientRect()
|
||
const spaceBelow = window.innerHeight - r.bottom - GAP - 8
|
||
const spaceAbove = r.top - GAP - 8
|
||
const up = spaceBelow < 180 && spaceAbove > spaceBelow
|
||
setPos({
|
||
left: r.left,
|
||
width: r.width,
|
||
top: up ? undefined : r.bottom + GAP,
|
||
bottom: up ? window.innerHeight - r.top + GAP : undefined,
|
||
maxHeight: Math.max(120, Math.min(MENU_MAX, up ? spaceAbove : spaceBelow))
|
||
})
|
||
}, [])
|
||
|
||
const close = useCallback(() => {
|
||
setOpen(false)
|
||
setQuery('')
|
||
}, [])
|
||
|
||
const openMenu = useCallback(() => {
|
||
if (disabled || open) return
|
||
place()
|
||
setQuery('')
|
||
const idx = options.findIndex((o) => String(o.value) === String(value ?? ''))
|
||
setActive(idx >= 0 ? idx : 0)
|
||
setOpen(true)
|
||
}, [disabled, open, options, place, value])
|
||
|
||
const pick = useCallback((option) => {
|
||
onChange?.(option.value, option)
|
||
setOpen(false)
|
||
setQuery('')
|
||
}, [onChange])
|
||
|
||
/* 展开期间:点外部收起、滚动/缩放时跟住触发区 */
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const onDocDown = (e) => {
|
||
if (rootRef.current?.contains(e.target)) return
|
||
if (menuRef.current?.contains(e.target)) return
|
||
close()
|
||
}
|
||
const onReflow = () => place()
|
||
document.addEventListener('mousedown', onDocDown)
|
||
window.addEventListener('resize', onReflow)
|
||
window.addEventListener('scroll', onReflow, true)
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDocDown)
|
||
window.removeEventListener('resize', onReflow)
|
||
window.removeEventListener('scroll', onReflow, true)
|
||
}
|
||
}, [open, close, place])
|
||
|
||
/* 键盘走到看不见的项时,把它滚进视野 */
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const el = listRef.current?.querySelector('.ss-opt.is-active')
|
||
el?.scrollIntoView({ block: 'nearest' })
|
||
}, [open, active])
|
||
|
||
const onKeyDown = (e) => {
|
||
// Esc 只收候选:必须拦住,否则抽屉/浮层的全局 Esc 会顺手把整层关掉
|
||
if (e.key === 'Escape') { e.stopPropagation(); close(); return }
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault()
|
||
if (!open) { openMenu(); return }
|
||
if (filtered.length === 0) return
|
||
setActive((i) => {
|
||
const next = e.key === 'ArrowDown' ? i + 1 : i - 1
|
||
return (next + filtered.length) % filtered.length
|
||
})
|
||
return
|
||
}
|
||
if (e.key === 'Enter') {
|
||
if (!open) return
|
||
e.preventDefault()
|
||
const option = filtered[active]
|
||
if (option) pick(option)
|
||
}
|
||
}
|
||
|
||
const showClear = clearable && !disabled && selected
|
||
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
className={`ss-root ss-${size}${open ? ' is-open' : ''}${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`}
|
||
style={style}
|
||
onMouseDown={(e) => {
|
||
if (disabled) return
|
||
if (e.target.closest('.ss-clear')) return
|
||
if (inputRef.current && document.activeElement !== inputRef.current) inputRef.current.focus()
|
||
if (!open) openMenu()
|
||
}}
|
||
// mousedown 之外再兜一层 click:真实鼠标两者都会来(第二次是 no-op),
|
||
// 但程序化 el.click() / 触摸设备只发 click,不补这层会「点了没反应」
|
||
onClick={() => { if (!open) openMenu() }}
|
||
>
|
||
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={open ? query : (selected?.label || '')}
|
||
placeholder={open ? searchPlaceholder : placeholder}
|
||
readOnly={!open}
|
||
disabled={disabled}
|
||
autoComplete="off"
|
||
role="combobox"
|
||
aria-expanded={open}
|
||
aria-autocomplete="list"
|
||
onChange={(e) => { setQuery(e.target.value); setActive(0) }}
|
||
onFocus={openMenu}
|
||
onKeyDown={onKeyDown}
|
||
/>
|
||
{showClear && (
|
||
<button
|
||
type="button"
|
||
className="ss-clear"
|
||
title="清除"
|
||
aria-label="清除"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => { onChange?.('', null); setQuery(''); inputRef.current?.focus() }}
|
||
>
|
||
<CloseIcon size={12} />
|
||
</button>
|
||
)}
|
||
<span className={`ss-caret${open ? ' is-up' : ''}`} aria-hidden="true"><ChevronDownIcon size={13} /></span>
|
||
|
||
{open && pos && createPortal(
|
||
<div
|
||
ref={menuRef}
|
||
className="ss-menu"
|
||
role="listbox"
|
||
style={{ left: pos.left, width: pos.width, top: pos.top, bottom: pos.bottom, maxHeight: pos.maxHeight }}
|
||
>
|
||
{filtered.length === 0 ? (
|
||
<div className="ss-empty">{emptyText}</div>
|
||
) : (
|
||
<div ref={listRef}>
|
||
{filtered.map((o, i) => (
|
||
<div
|
||
key={String(o.value)}
|
||
role="option"
|
||
aria-selected={i === active}
|
||
className={`ss-opt${i === active ? ' is-active' : ''}${selected && String(o.value) === String(value ?? '') ? ' is-selected' : ''}`}
|
||
onMouseEnter={() => setActive(i)}
|
||
onMouseDown={(e) => { e.preventDefault(); pick(o) }}
|
||
onClick={() => pick(o)}
|
||
>
|
||
<span className="ss-label">{o.label}</span>
|
||
{o.hint != null && o.hint !== '' && <span className="ss-hint">{o.hint}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 列表上方的内联搜索框:用于「列表本身」的即时过滤(供应商详情的供货清单、
|
||
* 产品详情里的货号表这类)。和 SearchSelect 同一套外观语言。
|
||
*/
|
||
export function SearchField({ value, onChange, placeholder = '搜索…', size = 'md', style, className = '' }) {
|
||
return (
|
||
<div className={`sf-root sf-${size}${className ? ` ${className}` : ''}`} style={style}>
|
||
<span className="ss-icon" aria-hidden="true"><SearchIcon size={size === 'sm' ? 13 : 14} /></span>
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
placeholder={placeholder}
|
||
autoComplete="off"
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
{value !== '' && value != null && (
|
||
<button
|
||
type="button"
|
||
className="ss-clear"
|
||
title="清除"
|
||
aria-label="清除"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => onChange('')}
|
||
>
|
||
<CloseIcon size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default SearchSelect
|