本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
105 lines
3.4 KiB
React
105 lines
3.4 KiB
React
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import axios from 'axios'
|
|
import { ImageIcon } from './Icons'
|
|
|
|
/**
|
|
* 货号缩略图 + 鼠标悬浮预览。
|
|
*
|
|
* 图片来自 happy 后端(4300)的 /product-images/*,同源访问,已无独立 SKU 服务。
|
|
* 图片挂在**产品组**上(不是每个货号一张),所以同一个组里的货号会共享同一张主图。
|
|
*/
|
|
|
|
export const thumbSrc = filename => (filename ? `/product-images/${encodeURIComponent(filename)}` : '')
|
|
|
|
const PREVIEW_WIDTH = 268
|
|
const PREVIEW_HEIGHT = 268
|
|
|
|
/**
|
|
* @param {string} sku 货号(用于预览卡上的标注)
|
|
* @param {string} filename 图片文件名(不带路径)
|
|
* @param {number} size 缩略图边长
|
|
* @param {boolean} preview 是否开启悬浮预览
|
|
*/
|
|
export function SkuThumb({ sku, filename, size = 34, preview = true, className = '' }) {
|
|
const [open, setOpen] = useState(false)
|
|
const [position, setPosition] = useState(null)
|
|
const anchorRef = useRef(null)
|
|
const src = thumbSrc(filename)
|
|
|
|
/* 预览是 fixed 浮层:滚动或改变视口就收起,免得悬在错位的半空中 */
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const close = () => setOpen(false)
|
|
window.addEventListener('scroll', close, true)
|
|
window.addEventListener('resize', close)
|
|
return () => {
|
|
window.removeEventListener('scroll', close, true)
|
|
window.removeEventListener('resize', close)
|
|
}
|
|
}, [open])
|
|
|
|
const show = () => {
|
|
const element = anchorRef.current
|
|
if (!element || !src) return
|
|
const rect = element.getBoundingClientRect()
|
|
let left = rect.right + 14
|
|
if (left + PREVIEW_WIDTH > window.innerWidth - 12) {
|
|
left = Math.max(12, rect.left - PREVIEW_WIDTH - 14)
|
|
}
|
|
let top = rect.top + rect.height / 2 - PREVIEW_HEIGHT / 2
|
|
top = Math.max(12, Math.min(top, window.innerHeight - PREVIEW_HEIGHT - 12))
|
|
setPosition({ left, top })
|
|
setOpen(true)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<span
|
|
ref={anchorRef}
|
|
className={`sku-thumb${src ? '' : ' is-empty'}${className ? ' ' + className : ''}`}
|
|
style={{ width: size, height: size }}
|
|
onMouseEnter={preview && src ? show : undefined}
|
|
onMouseLeave={preview ? () => setOpen(false) : undefined}
|
|
title={sku ? `货号 ${sku}` : undefined}
|
|
>
|
|
{src
|
|
? <img src={src} alt="" loading="lazy" />
|
|
: <ImageIcon size={Math.max(13, Math.round(size * 0.42))} />}
|
|
</span>
|
|
{open && position && createPortal(
|
|
<span className="sku-thumb-preview" style={{ left: position.left, top: position.top }}>
|
|
<img src={src} alt="" />
|
|
{sku ? <b>{sku}</b> : null}
|
|
</span>,
|
|
document.body
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 批量取货号缩略图。
|
|
* 只关心「货号 → 文件名」这一层映射,一次请求拿一批,避免每个货号打一次接口。
|
|
*/
|
|
export function useSkuThumbs(skus) {
|
|
const [map, setMap] = useState({})
|
|
const key = useMemo(
|
|
() => [...new Set((skus || []).filter(Boolean).map(item => String(item)))].sort().join(','),
|
|
[skus]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!key) { setMap({}); return }
|
|
let alive = true
|
|
axios.get('/api/sku/thumbs', { params: { skus: key } })
|
|
.then(({ data }) => { if (alive) setMap(data?.thumbs || {}) })
|
|
.catch(() => { if (alive) setMap({}) })
|
|
return () => { alive = false }
|
|
}, [key])
|
|
|
|
return map
|
|
}
|
|
|
|
export default SkuThumb
|