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
+601
View File
@@ -0,0 +1,601 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import Drawer from '../components/Drawer'
import SkuPicker from '../components/SkuPicker'
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
import {
PlusCircleIcon, PencilIcon, TrashIcon, PackageIcon, ClockIcon,
InquiryIcon, DollarIcon, RefreshIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const STATUS_LABELS = {
draft: '草稿',
quoting: '报价中',
quoted: '已报价',
won: '已成交',
lost: '已流失',
closed: '已关闭'
}
const STATUS_TONE = {
draft: 'mute',
quoting: 'warn',
quoted: 'ok',
won: 'ok',
lost: 'danger',
closed: 'mute'
}
const CURRENCIES = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD']
/**
* 报价合计。
* 口径说明:inquiry_items.currency 存的是「采购币种」,而 EXW 是拿采购价除以汇率算出来的,
* 所以 EXW / 小计 / 合计都落在「报价币种」上,也就是 inquiry.currency。
* 这里不做跨币种换算 —— 一张询盘的报价币种是固定的(汇率那一栏的分母就是它)。
*/
function summarize(items) {
return items.reduce((sum, item) => sum + Number(item.exw || 0) * Number(item.quantity || 0), 0)
}
function money(value, digits = 2) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/** 包装快照读出来做「一行摘要」,让报价行不用展开就知道箱规 */
function packagingBrief(snapshot) {
if (!snapshot) return null
let data = null
try { data = JSON.parse(snapshot) } catch { return null }
if (!data) return null
const parts = []
if (data.carton_qty) parts.push(`${data.carton_qty} pcs/箱`)
if (data.carton_length && data.carton_width && data.carton_height) {
parts.push(`${data.carton_length}×${data.carton_width}×${data.carton_height} cm`)
}
if (data.carton_weight) parts.push(`毛重 ${data.carton_weight} kg`)
if (data.packaging_material) parts.push(data.packaging_material)
return parts.length ? parts : null
}
/**
* 询盘明细编辑器。
* 两种输入方式:
* 自动 —— 填加价率,EXW 由「采购价 ÷ 汇率 ×(1+加价率)」算出(这是报价的标准姿势)
* 手动 —— 直接给 EXW,反算加价率(客户先给目标价时用)
* 两者互斥,切换时保留当前能推导的量,避免把用户已经填好的数字清掉。
*/
function ItemEditor({ inquiry, item, existingSkus = [], onClose, onSaved, canEdit }) {
const isNew = !item
const [skuChoice, setSkuChoice] = useState(isNew ? null : { sku: item.sku, display_sku: item.sku, source: 'existing' })
const [pickerOpen, setPickerOpen] = useState(isNew)
const [mode, setMode] = useState('auto')
const [form, setForm] = useState(() => ({
purchase_price: item ? String(item.purchase_price ?? '') : '',
// 采购币种默认取人民币 —— 供应商报价绝大多数是 CNY,拿询盘的报价币种当默认值会把人带偏
currency: item?.currency || 'CNY',
exchange_rate: item ? String(item.exchange_rate ?? '') : '',
markup_rate: item ? String(((Number(item.markup_rate) || 0) * 100).toFixed(2).replace(/\.?0+$/, '')) : '25',
manual_exw: '',
quantity: item ? String(item.quantity ?? '') : '1'
}))
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const purchasePrice = Number(form.purchase_price)
const exchangeRate = Number(form.exchange_rate)
const markupRate = Number(form.markup_rate) / 100
const manualExw = Number(form.manual_exw)
const base = Number.isFinite(purchasePrice) && Number.isFinite(exchangeRate) && exchangeRate > 0
? purchasePrice / exchangeRate
: null
const preview = useMemo(() => {
if (base === null) return null
if (mode === 'manual') {
if (!Number.isFinite(manualExw) || manualExw <= 0) return null
return { exw: manualExw, markup: manualExw / base - 1 }
}
if (!Number.isFinite(markupRate) || markupRate < -1) return null
return { exw: base * (1 + markupRate), markup: markupRate }
}, [mode, base, manualExw, markupRate])
const pickSku = async (choice) => {
setSkuChoice(choice)
setPickerOpen(false)
setError('')
// 命中 SKU 服务时返回的是完整货号,顺手把采购价/币种带出来,省一次手输
if (choice.source === 'manual') return
setForm(current => ({
...current,
purchase_price: choice.price === null || choice.price === undefined ? current.purchase_price : String(choice.price),
currency: choice.currency || current.currency
}))
}
const submit = async (event) => {
event.preventDefault()
if (!skuChoice?.sku) { setError('请先选择货号'); return }
const quantity = Number(form.quantity)
if (!Number.isInteger(quantity) || quantity <= 0) { setError('数量必须是大于 0 的整数'); return }
if (!Number.isFinite(purchasePrice) || purchasePrice <= 0) { setError('采购价必须是大于 0 的数字'); return }
if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { setError('汇率必须是大于 0 的数字'); return }
const payload = {
sku: skuChoice.sku,
purchase_price: purchasePrice,
currency: form.currency,
exchange_rate: exchangeRate,
quantity
}
if (mode === 'manual') {
if (!Number.isFinite(manualExw) || manualExw <= 0) { setError('手动 EXW 必须是大于 0 的数字'); return }
payload.manual_exw = manualExw
} else {
payload.markup_rate = markupRate
}
setSaving(true)
setError('')
try {
if (isNew) await axios.post(`/api/inquiries/${inquiry.id}/items`, payload)
else await axios.patch(`/api/inquiry-items/${item.id}`, payload)
onSaved()
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
const brief = item ? packagingBrief(item.packaging_snapshot) : null
return (
<>
<Drawer
wide
title={isNew ? '添加报价明细' : `编辑明细 · ${item.sku}`}
sub={isNew ? `询盘 ${inquiry?.inquiry_no || ''}` : item.product_name}
onClose={onClose}
footer={(
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
{canEdit && (
<button type="submit" form="inquiry-item-form" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存明细'}
</button>
)}
</>
)}
>
<form id="inquiry-item-form" onSubmit={submit}>
<div className="panel-title">货号</div>
<div className="sku-chosen">
{skuChoice ? (
<>
<div className="sku-chosen-main">
<SkuThumb sku={skuChoice.sku} filename={skuChoice.thumb} size={36} />
<b>{skuChoice.display_sku || skuChoice.sku}</b>
<span>{skuChoice.name || skuChoice.spec || (skuChoice.source === 'manual' ? '手工输入货号' : '')}</span>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPickerOpen(true)}>
更换货号
</button>
)}
</>
) : (
<p className="muted-note">还没有选择货号。</p>
)}
</div>
<div className="panel-title">报价参数</div>
<div className="form-row">
<div className="form-group">
<label>采购价(含税单价)</label>
<input
type="number" min="0" step="0.01" inputMode="decimal"
value={form.purchase_price}
onChange={event => setForm({ ...form, purchase_price: event.target.value })}
/>
</div>
<div className="form-group">
<label>采购币种</label>
<select value={form.currency} onChange={event => setForm({ ...form, currency: event.target.value })}>
{CURRENCIES.map(currency => <option key={currency} value={currency}>{currency}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>汇率(1 {inquiry?.currency || '报价币'} = ? {form.currency})</label>
<input
type="number" min="0" step="0.0001" inputMode="decimal"
value={form.exchange_rate}
placeholder="例如 6.7"
onChange={event => setForm({ ...form, exchange_rate: event.target.value })}
/>
</div>
<div className="form-group">
<label>数量</label>
<input
type="number" min="1" step="1" inputMode="numeric"
value={form.quantity}
onChange={event => setForm({ ...form, quantity: event.target.value })}
/>
</div>
</div>
<div className="panel-title">定价方式</div>
<div className="mode-switch" role="tablist">
<button
type="button" role="tab" aria-selected={mode === 'auto'}
className={mode === 'auto' ? 'is-active' : ''}
onClick={() => setMode('auto')}
>
按加价率算 EXW
</button>
<button
type="button" role="tab" aria-selected={mode === 'manual'}
className={mode === 'manual' ? 'is-active' : ''}
onClick={() => setMode('manual')}
>
手填 EXW 反算加价率
</button>
</div>
<div className="form-row">
{mode === 'auto' ? (
<div className="form-group">
<label>加价率</label>
<span className="pkg-input">
<input
type="number" step="0.1" inputMode="decimal"
value={form.markup_rate}
placeholder="例如 25"
onChange={event => setForm({ ...form, markup_rate: event.target.value })}
/>
<em>%</em>
</span>
</div>
) : (
<div className="form-group">
<label>目标 EXW 单价</label>
<input
type="number" min="0" step="0.01" inputMode="decimal"
value={form.manual_exw}
placeholder="客户给的目标价"
onChange={event => setForm({ ...form, manual_exw: event.target.value })}
/>
</div>
)}
<div className="form-group">
<label>{mode === 'auto' ? '加价系数' : '反算加价系数'}</label>
<div className="readout">
{preview ? (preview.markup + 1).toFixed(4) : '—'}
</div>
</div>
</div>
<div className="exw-preview">
<div className="exw-preview-formula">
EXW = 采购价 ÷ 汇率 ×(1 + 加价率)
</div>
<div className="exw-preview-main">
<span>
EXW 单价
<em className="exw-preview-currency">报价币种 {inquiry?.currency || '—'}</em>
</span>
<strong>{preview ? `${money(preview.exw, 4)} ${inquiry?.currency || ''}` : '—'}</strong>
</div>
<div className="exw-preview-sub">
{base === null ? (
<span>填好采购价和汇率后自动计算。</span>
) : (
<>
<span>采购价折报价币 <b>{money(base, 4)}</b></span>
<span>加价率 <b>{preview ? `${(preview.markup * 100).toFixed(2)}%` : '—'}</b></span>
<span>小计 <b>{preview ? `${money(preview.exw * Number(form.quantity || 0), 2)} ${inquiry?.currency || ''}` : '—'}</b></span>
</>
)}
</div>
</div>
{brief && (
<>
<div className="panel-title">包装(本次报价时的快照)</div>
<ul className="snapshot-list">
{brief.map(text => <li key={text}><PackageIcon size={13} /> {text}</li>)}
</ul>
<p className="muted-note">快照在报价那一刻定格,之后产品包装信息再改也不会动这行报价。</p>
</>
)}
{error && <p className="form-error">{error}</p>}
</form>
</Drawer>
{pickerOpen && (
<div className="modal-overlay" onClick={() => (isNew ? onClose() : setPickerOpen(false))}>
<div className="modal-content sku-picker-modal" onClick={event => event.stopPropagation()}>
<SkuPicker
excludeSkus={existingSkus}
onClose={() => (isNew ? onClose() : setPickerOpen(false))}
onPick={pickSku}
/>
</div>
</div>
)}
</>
)
}
function InquiryDetail() {
const { id } = useParams()
const { user } = useAuth()
const canEdit = hasPermission(user, 'inquiry.edit')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editor, setEditor] = useState(null) // { item } 或 { item: null } 表示新增
const [expandedPackaging, setExpandedPackaging] = useState(null)
/* 明细里的货号只在本地库里,图片得按货号批量问 SKU 服务要 */
const itemThumbs = useSkuThumbs((data?.items || []).map(row => row.sku))
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/api/inquiries/${id}`)
setData(response.data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取询盘失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
const updateStatus = async (status) => {
try {
await axios.patch(`/api/inquiries/${id}`, { status })
await load()
} catch (statusError) {
setError(statusError?.response?.data?.error || '更新状态失败')
}
}
const removeItem = async (item) => {
if (!window.confirm(`删除明细「${item.product_name || item.sku}」?删除后不可恢复。`)) return
try {
await axios.delete(`/api/inquiry-items/${item.id}`)
await load()
} catch (removeError) {
setError(removeError?.response?.data?.error || '删除失败,请稍后重试')
}
}
const inquiry = data?.inquiry
const items = data?.items || []
const totals = useMemo(() => summarize(items), [items])
if (loading) return <div className="loading">加载询盘…</div>
if (!inquiry) {
return (
<div className="page-container">
<p className="notice-inline">{error || '询盘不存在'}</p>
<Link className="btn btn-secondary btn-sm" to="/inquiries">返回询盘列表</Link>
</div>
)
}
return (
<div className="page-container">
<div className="breadcrumb">
<Link to="/inquiries">询盘管理</Link>
<span>/</span>
<span className="mono">{inquiry.inquiry_no}</span>
</div>
<div className="page-header">
<div>
<h1 className="page-title">{inquiry.title || '未命名询盘'}</h1>
<p className="page-sub">
<Link to={`/customers/${inquiry.customer_id}`}>{inquiry.customer_name || '未知客户'}</Link>
{' · '}
{inquiry.inquiry_date}
{' · '}
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
{canEdit && (
<button type="button" className="btn btn-primary btn-sm" onClick={() => setEditor({ item: null })}>
<PlusCircleIcon size={15} /> 添加货号
</button>
)}
</div>
</div>
<div className="stat-rail inquiry-rail">
<div className="stat-line">
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
<span>报价明细</span><strong>{items.length}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><PackageIcon size={17} /></span>
<span>总数量</span>
<strong>{items.reduce((sum, item) => sum + Number(item.quantity || 0), 0).toLocaleString('zh-CN')}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><DollarIcon size={17} /></span>
<span>报价合计</span>
<strong>
{items.length ? `${money(totals, 2)} ${inquiry.currency || ''}` : '—'}
</strong>
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><DollarIcon size={17} /> 报价明细</h2>
</div>
<div className="header-actions">
{canEdit ? (
<label className="inline-field">
<span>询盘状态</span>
<select value={inquiry.status} onChange={event => updateStatus(event.target.value)}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
) : (
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
{STATUS_LABELS[inquiry.status] || inquiry.status}
</span>
)}
</div>
</div>
{items.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">这次询盘还没有报价行</p>
<p className="empty-state-hint">点右上角「添加货号」,从已有 SKU 里选,或直接敲完整货号。</p>
</div>
) : (
<div className="table-wrap">
<table className="data-table quote-table">
<thead>
<tr>
<th>货号</th>
<th>产品 / 供应商</th>
<th className="num">采购价</th>
<th className="num">汇率</th>
<th className="num">加价率</th>
<th className="num">加价系数</th>
<th className="num">EXW<span className="th-unit">{inquiry.currency}</span></th>
<th className="num">数量</th>
<th className="num">小计<span className="th-unit">{inquiry.currency}</span></th>
<th>包装</th>
<th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map(item => {
const brief = packagingBrief(item.packaging_snapshot)
const open = expandedPackaging === item.id
const subtotal = Number(item.exw || 0) * Number(item.quantity || 0)
return (
<React.Fragment key={item.id}>
<tr>
<td className="mono bold">
<span className="sku-cell">
<SkuThumb sku={item.sku} filename={itemThumbs[item.sku]} size={30} />
{item.sku}
</span>
</td>
<td>
<div>{item.product_name || '—'}</div>
<div className="cell-sub">{item.supplier_name || '未指派供应商'}</div>
</td>
<td className="num">{money(item.purchase_price, 2)} <span className="unit">{item.currency}</span></td>
<td className="num">{money(item.exchange_rate, 4)}</td>
<td className="num">{(Number(item.markup_rate || 0) * 100).toFixed(2)}%</td>
<td className="num">{money(item.markup_factor, 4)}</td>
<td className="num strong-cell">{money(item.exw, 4)}</td>
<td className="num">{Number(item.quantity || 0).toLocaleString('zh-CN')}</td>
<td className="num">{money(subtotal, 2)}</td>
<td>
{brief ? (
<button
type="button"
className={`packaging-chip${open ? ' is-open' : ''}`}
onClick={() => setExpandedPackaging(open ? null : item.id)}
title="展开包装快照"
>
<PackageIcon size={13} /> {brief[0]}
</button>
) : (
<span className="muted-note">未维护</span>
)}
</td>
<td className="col-actions">
{canEdit && (
<button type="button" className="icon-btn" title="编辑明细" onClick={() => setEditor({ item })}>
<PencilIcon size={14} />
</button>
)}
{canEdit && (
<button type="button" className="icon-btn" title="删除明细" onClick={() => removeItem(item)}>
<TrashIcon size={14} />
</button>
)}
</td>
</tr>
{open && brief && (
<tr className="snapshot-row">
<td colSpan={11}>
<div className="snapshot-detail">
<span className="snapshot-detail-title"><PackageIcon size={13} /> 报价时的包装快照</span>
<ul className="snapshot-list">
{brief.map(text => <li key={text}>{text}</li>)}
</ul>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><ClockIcon size={17} /> 跟进记录</h2>
</div>
<Link className="btn btn-secondary btn-sm" to={`/customers/${inquiry.customer_id}`}>
去客户详情记录跟进
</Link>
</div>
<p className="muted-note">
跟进记录统一挂在客户身上,记录时可以在「关联询盘」里选中本次询盘({inquiry.inquiry_no}),
这样客户时间线和工作台待跟进都能对上。
</p>
</section>
{editor && (
<ItemEditor
inquiry={inquiry}
item={editor.item}
existingSkus={items.filter(row => row.id !== editor.item?.id).map(row => row.sku)}
onClose={() => setEditor(null)}
onSaved={() => { setEditor(null); load() }}
canEdit={canEdit}
/>
)}
</div>
)
}
export default InquiryDetail