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:
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { CalculatorIcon, ChevronDownIcon, RefreshIcon, SwapIcon } from './Icons'
|
||||
import CurrencyFlag from './CurrencyFlag'
|
||||
import {
|
||||
buildCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
|
||||
buildPrimaryCurrencyOptions, crossRate, currencyLabel, formatAmount, formatRate, parseAmount
|
||||
} from '../currencies'
|
||||
import { amountToWords } from '../amountInWords'
|
||||
|
||||
const DEFAULT_FROM = 'USD'
|
||||
const DEFAULT_TO = 'CNY'
|
||||
@@ -41,9 +43,16 @@ function CurrencyPicker({ value, options, onChange, align }) {
|
||||
const filtered = useMemo(() => {
|
||||
const term = keyword.trim().toUpperCase()
|
||||
if (!term) return options
|
||||
return options.filter(item => item.code.includes(term) || (item.name && item.name.includes(keyword.trim())))
|
||||
const raw = keyword.trim()
|
||||
return options.filter(item =>
|
||||
item.code.includes(term)
|
||||
|| (item.name && item.name.includes(raw))
|
||||
|| (item.region && item.region.includes(raw))
|
||||
)
|
||||
}, [options, keyword])
|
||||
|
||||
const labelOf = item => `${item.code} ${item.name}${item.region ? ` · ${item.region}` : ''}`
|
||||
|
||||
return (
|
||||
<div className={`fx-picker ${align}`} ref={boxRef}>
|
||||
<button
|
||||
@@ -54,6 +63,7 @@ function CurrencyPicker({ value, options, onChange, align }) {
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<CurrencyFlag code={value} width={20} />
|
||||
<b>{value}</b>
|
||||
<span>{currencyLabel(value).slice(4) || '—'}</span>
|
||||
<ChevronDownIcon size={13} />
|
||||
@@ -78,9 +88,11 @@ function CurrencyPicker({ value, options, onChange, align }) {
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.code === value}
|
||||
title={labelOf(item)}
|
||||
className={`fx-picker-item${item.code === value ? ' active' : ''}`}
|
||||
onClick={() => { onChange(item.code); setOpen(false) }}
|
||||
>
|
||||
<CurrencyFlag code={item.code} width={20} />
|
||||
<b>{item.code}</b>
|
||||
<span>{item.name || '—'}</span>
|
||||
</button>
|
||||
@@ -105,6 +117,7 @@ function FxCalculator() {
|
||||
/* 只存「用户正在输入的那一侧」的原始文本,另一侧永远是算出来的。
|
||||
这样不存在两个输入框互相改、改出抖动的可能。 */
|
||||
const [entry, setEntry] = useState({ value: '1000', side: 'from' })
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const panelRef = useRef(null)
|
||||
const loadedRef = useRef(false)
|
||||
@@ -164,7 +177,8 @@ function FxCalculator() {
|
||||
try { localStorage.setItem(PAIR_STORAGE_KEY, JSON.stringify(pair)) } catch { /* 忽略 */ }
|
||||
}, [pair])
|
||||
|
||||
const options = useMemo(() => buildCurrencyOptions(rates, [DEFAULT_FROM, DEFAULT_TO, 'EUR', 'GBP', 'JPY']), [rates])
|
||||
/* 只给主流 13 种币种(见 currencies.js 的 PRIMARY_CURRENCIES),不跟随接口的全量列表 */
|
||||
const options = useMemo(() => buildPrimaryCurrencyOptions(), [])
|
||||
const rate = crossRate(rates, from, to) // 1 from = ? to
|
||||
const inverse = crossRate(rates, to, from) // 1 to = ? from
|
||||
const num = parseAmount(entry.value)
|
||||
@@ -200,6 +214,23 @@ function FxCalculator() {
|
||||
? new Date(meta.updatedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
/* 英文大写金额跟随左侧(from)的金额与币种 —— 单证上写的就是报价币种那一侧 */
|
||||
const words = useMemo(() => {
|
||||
const value = parseAmount(fromDisplay)
|
||||
return value === null ? '' : amountToWords(value, from)
|
||||
}, [fromDisplay, from])
|
||||
|
||||
const copyWords = async () => {
|
||||
if (!words) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(words)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1600)
|
||||
} catch {
|
||||
/* 剪贴板不可用(非安全上下文等)时不打断使用,手动选中也能复制 */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fx-calc" ref={panelRef}>
|
||||
<button
|
||||
@@ -271,6 +302,20 @@ function FxCalculator() {
|
||||
: '—'}
|
||||
</div>
|
||||
|
||||
<div className="fx-words">
|
||||
<div className="fx-words-head">
|
||||
<span className="fx-words-label">英文大写金额</span>
|
||||
{words && (
|
||||
<button type="button" className="fx-copy" onClick={copyWords}>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={`fx-words-text${words ? '' : ' is-empty'}`}>
|
||||
{words || '在左侧输入金额后自动生成'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="fx-panel-foot">
|
||||
{meta?.warning
|
||||
? <span className="fx-warn">{meta.warning}</span>
|
||||
|
||||
Reference in New Issue
Block a user