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:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+260
View File
@@ -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