feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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
|
||||
Reference in New Issue
Block a user