本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
144 lines
7.0 KiB
JavaScript
144 lines
7.0 KiB
JavaScript
/**
|
||
* 币种清单(顶栏汇率计算器用)
|
||
*
|
||
* 只是「展示用」的名字与排序。真正有哪些币种、汇率多少,全部以
|
||
* 后端 /api/fx/rates 返回的 rates 为准 —— 接口给了 160+ 币种,
|
||
* 这份清单里没列到的也会照常出现在下拉里(只显示代码)。
|
||
*
|
||
* ORDER 决定下拉里的先后(外贸常用币种在前),未列出的按字母序排在后面。
|
||
*/
|
||
export const CURRENCY_NAMES = {
|
||
USD: '美元', CNY: '人民币', EUR: '欧元', GBP: '英镑', HKD: '港币', JPY: '日元',
|
||
AUD: '澳元', CAD: '加元', SGD: '新加坡元', CHF: '瑞士法郎', NZD: '新西兰元', KRW: '韩元',
|
||
AED: '阿联酋迪拉姆', SAR: '沙特里亚尔', RUB: '卢布', INR: '印度卢比', BRL: '巴西雷亚尔',
|
||
MXN: '墨西哥比索', THB: '泰铢', VND: '越南盾', MYR: '马来西亚林吉特', IDR: '印尼盾',
|
||
PHP: '菲律宾比索', TRY: '土耳其里拉', ZAR: '南非兰特', PLN: '波兰兹罗提',
|
||
SEK: '瑞典克朗', NOK: '挪威克朗', DKK: '丹麦克朗', EGP: '埃及镑', NGN: '尼日利亚奈拉',
|
||
PKR: '巴基斯坦卢比', BDT: '孟加拉塔卡', KES: '肯尼亚先令', KWD: '科威特第纳尔',
|
||
QAR: '卡塔尔里亚尔', OMR: '阿曼里亚尔', BHD: '巴林第纳尔', ILS: '以色列谢克尔',
|
||
TWD: '新台币', CLP: '智利比索', COP: '哥伦比亚比索', PEN: '秘鲁索尔', ARS: '阿根廷比索',
|
||
UAH: '乌克兰格里夫纳', CZK: '捷克克朗', HUF: '匈牙利福林', RON: '罗马尼亚列伊',
|
||
LKR: '斯里兰卡卢比', NPR: '尼泊尔卢比', MMK: '缅甸元', KHR: '柬埔寨瑞尔',
|
||
TZS: '坦桑尼亚先令', MAD: '摩洛哥迪拉姆', GHS: '加纳塞地', ETB: '埃塞俄比亚比尔',
|
||
UGX: '乌干达先令', ZMW: '赞比亚克瓦查', DZD: '阿尔及利亚第纳尔', TND: '突尼斯第纳尔',
|
||
JOD: '约旦第纳尔', IQD: '伊拉克第纳尔', LBP: '黎巴嫩镑', AZN: '阿塞拜疆马纳特',
|
||
KZT: '哈萨克斯坦坚戈', UZS: '乌兹别克斯坦苏姆', GEL: '格鲁吉亚拉里', AMD: '亚美尼亚德拉姆',
|
||
MNT: '蒙古图格里克', KGS: '吉尔吉斯斯坦索姆', BND: '文莱元', MOP: '澳门元',
|
||
ISK: '冰岛克朗', UYU: '乌拉圭比索', BOB: '玻利维亚诺', PYG: '巴拉圭瓜拉尼',
|
||
CRC: '哥斯达黎加科朗', GTQ: '危地马拉格查尔', DOP: '多米尼加比索', JMD: '牙买加元',
|
||
TTD: '特立尼达和多巴哥元', RSD: '塞尔维亚第纳尔', BGN: '保加利亚列弗',
|
||
HRK: '克罗地亚库纳', ALL: '阿尔巴尼亚列克', MKD: '北马其顿第纳尔'
|
||
}
|
||
|
||
/**
|
||
* 汇率计算器用的「主流币种」白名单,按 2024 年中国对其出口金额从高到低排。
|
||
*
|
||
* 排序依据:联合国 COMTRADE / Trading Economics 2024 年中国出口目的地金额。
|
||
* 美国 5257 亿 > 中国香港 2910 亿 > 日本 1520 亿 > 俄罗斯 1153 亿 >
|
||
* 马来西亚 1015 亿 > 新加坡 791 亿 > 中国台湾 753 亿 > 澳大利亚 708 亿 >
|
||
* 阿联酋 656 亿 > 沙特 500 亿 > 加拿大 465 亿。
|
||
* 欧元按欧元区合计(德 1071 亿 + 荷 912 亿 + 意 462 亿 + 法 450 亿 + 西 409 亿 …)
|
||
* 计入,量级仅次于美国,所以排在美国之后。人民币是本币,按需求紧随美元。
|
||
*/
|
||
export const PRIMARY_CURRENCIES = [
|
||
'USD', 'CNY', 'EUR', 'HKD', 'JPY', 'RUB', 'MYR', 'SGD', 'TWD', 'AUD', 'AED', 'SAR', 'CAD'
|
||
]
|
||
|
||
/** 币种对应的国家 / 地区(中国香港、中国台湾按中国区划标注)。 */
|
||
export const CURRENCY_REGIONS = {
|
||
USD: '美国', CNY: '中国', EUR: '欧元区', HKD: '中国香港', JPY: '日本',
|
||
RUB: '俄罗斯', MYR: '马来西亚', SGD: '新加坡', TWD: '中国台湾', AUD: '澳大利亚',
|
||
AED: '阿联酋', SAR: '沙特阿拉伯', CAD: '加拿大'
|
||
}
|
||
|
||
/**
|
||
* 汇率计算器的币种下拉:只给主流 13 种,顺序完全由 PRIMARY_CURRENCIES 决定。
|
||
* 不用看接口返回的 160+ 币种 —— 这是「常用币种快捷入口」,不是全量币种表。
|
||
*/
|
||
export function buildPrimaryCurrencyOptions() {
|
||
return PRIMARY_CURRENCIES.map(code => ({
|
||
code,
|
||
name: CURRENCY_NAMES[code] || '',
|
||
region: CURRENCY_REGIONS[code] || ''
|
||
}))
|
||
}
|
||
|
||
/** 下拉排序权重:外贸结算最常见的排前面。 */
|
||
const ORDER = [
|
||
'USD', 'CNY', 'EUR', 'GBP', 'HKD', 'JPY', 'AUD', 'CAD', 'SGD', 'CHF', 'NZD', 'KRW',
|
||
'AED', 'SAR', 'RUB', 'INR', 'BRL', 'MXN', 'THB', 'VND', 'MYR', 'IDR', 'PHP', 'TRY',
|
||
'ZAR', 'PLN', 'SEK', 'NOK', 'DKK', 'TWD', 'EGP', 'NGN', 'PKR', 'BDT', 'KES', 'KWD',
|
||
'QAR', 'OMR', 'BHD', 'ILS', 'CLP', 'COP', 'PEN', 'ARS', 'UAH', 'CZK', 'HUF', 'RON'
|
||
]
|
||
|
||
const RANK = new Map(ORDER.map((code, index) => [code, index]))
|
||
|
||
/**
|
||
* 把接口返回的 rates 变成下拉选项,按常用度排序。
|
||
* @param {Record<string, number>} rates 以 USD 为基准的汇率表
|
||
* @param {string[]} extra 兜底币种(接口没回来时也要有 USD/CNY 可选)
|
||
*/
|
||
export function buildCurrencyOptions(rates, extra = ['USD', 'CNY']) {
|
||
const codes = Object.keys(rates || {})
|
||
const merged = Array.from(new Set([...extra, ...codes]))
|
||
return merged
|
||
.map(code => ({ code, name: CURRENCY_NAMES[code] || '' }))
|
||
.sort((a, b) => {
|
||
const ra = RANK.has(a.code) ? RANK.get(a.code) : 999
|
||
const rb = RANK.has(b.code) ? RANK.get(b.code) : 999
|
||
if (ra !== rb) return ra - rb
|
||
return a.code.localeCompare(b.code)
|
||
})
|
||
}
|
||
|
||
/** "USD 美元" / 没中文名时只显示代码 */
|
||
export function currencyLabel(code) {
|
||
const name = CURRENCY_NAMES[code]
|
||
return name ? `${code} ${name}` : code
|
||
}
|
||
|
||
/**
|
||
* 交叉汇率:1 from = ? to
|
||
* rates 以 USD 为基准,所以 rates[to] / rates[from] 就是任意两币之间的汇率。
|
||
*/
|
||
export function crossRate(rates, from, to) {
|
||
if (!rates) return null
|
||
const rf = Number(rates[from])
|
||
const rt = Number(rates[to])
|
||
if (!Number.isFinite(rf) || !Number.isFinite(rt) || rf <= 0) return null
|
||
return rt / rf
|
||
}
|
||
|
||
/**
|
||
* 金额显示:汇率结果的有效位数差别很大
|
||
* (1 CNY ≈ 0.00004 USD,1 USD ≈ 25000 VND),
|
||
* 固定两位小数会让小额变 0、大额全是没意义的零,所以按数量级取精度。
|
||
*/
|
||
export function formatAmount(value) {
|
||
if (!Number.isFinite(value)) return ''
|
||
const abs = Math.abs(value)
|
||
let digits
|
||
if (abs === 0) digits = 2
|
||
else if (abs >= 1000) digits = 2
|
||
else if (abs >= 1) digits = 4
|
||
else if (abs >= 0.001) digits = 6
|
||
else digits = 8
|
||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: digits })
|
||
}
|
||
|
||
/** 汇率本身用 6 位有效数字展示:0.000131 这种小汇率也能看清。 */
|
||
export function formatRate(value) {
|
||
if (!Number.isFinite(value)) return ''
|
||
if (value >= 1000) return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||
if (value >= 1) return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })
|
||
return value.toPrecision(4)
|
||
}
|
||
|
||
/** 把输入框里的字符串转成数字;空串、半截输入("1.")都返回 null 而不是崩掉。 */
|
||
export function parseAmount(text) {
|
||
const cleaned = String(text ?? '').replace(/[,\s]/g, '')
|
||
if (!cleaned) return null
|
||
const num = Number(cleaned)
|
||
return Number.isFinite(num) ? num : null
|
||
}
|