Files
happy-export/frontend/src/components/PackagingPanel.jsx
T
Drsakura 13d67c804f 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
2026-09-17 14:53:02 +08:00

264 lines
9.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import axios from 'axios'
import { PackageIcon, RefreshIcon } from './Icons'
/* 常用柜型内容积(m³)。取行业常用的保守值,宁少不溢柜。 */
const CONTAINERS = [
{ key: '20GP', label: '20GP', volume: 28 },
{ key: '40GP', label: '40GP', volume: 58 },
{ key: '40HQ', label: '40HQ', volume: 68 }
]
const FIELDS = [
{ key: 'inner_pack_qty', label: '内盒装量', unit: 'pcs/内盒', group: 'pack' },
{ key: 'carton_qty', label: '外箱装量', unit: 'pcs/箱', group: 'pack' },
{ key: 'carton_length', label: '外箱长', unit: 'cm', group: 'carton' },
{ key: 'carton_width', label: '外箱宽', unit: 'cm', group: 'carton' },
{ key: 'carton_height', label: '外箱高', unit: 'cm', group: 'carton' },
{ key: 'carton_weight', label: '外箱毛重', unit: 'kg', group: 'carton' },
{ key: 'unit_length', label: '单品长', unit: 'cm', group: 'unit' },
{ key: 'unit_width', label: '单品宽', unit: 'cm', group: 'unit' },
{ key: 'unit_height', label: '单品高', unit: 'cm', group: 'unit' },
{ key: 'unit_weight', label: '单品净重', unit: 'kg', group: 'unit' }
]
const EMPTY = FIELDS.reduce((acc, field) => { acc[field.key] = ''; return acc }, { packaging_material: '', notes: '' })
/** 算外箱体积与各柜型装柜量;字段不全就返回 null,不猜。 */
function calcLoading(form) {
const l = Number(form.carton_length)
const w = Number(form.carton_width)
const h = Number(form.carton_height)
const perCarton = Number(form.carton_qty)
if (![l, w, h].every(value => Number.isFinite(value) && value > 0)) return null
const cartonVolume = (l * w * h) / 1000000
if (!(cartonVolume > 0)) return null
const rows = CONTAINERS.map(container => {
const cartons = Math.floor(container.volume / cartonVolume)
return {
...container,
cartons,
pieces: Number.isFinite(perCarton) && perCarton > 0 ? cartons * perCarton : null
}
})
let totalWeight = null
const gross = Number(form.carton_weight)
if (Number.isFinite(gross) && gross > 0) totalWeight = rows.map(row => row.cartons * gross)
return { cartonVolume, rows, totalWeight }
}
/** 把接口返回的对象转成受控表单值(null → 空串,避免 React 受控告警) */
function toForm(data) {
const next = { ...EMPTY }
FIELDS.forEach(field => {
const value = data?.[field.key]
next[field.key] = value === null || value === undefined ? '' : String(value)
})
next.packaging_material = data?.packaging_material || ''
next.notes = data?.notes || ''
return next
}
/**
* 结构化包装信息面板。
* 只负责给「包装」这块数据提供录入与展示,SKU 是否存在由调用方保证
* (后端 PUT 会自己向 SKU 服务核验一次)。
*/
function PackagingPanel({ sku, readOnly = false, onSaved, canEdit }) {
const [form, setForm] = useState(EMPTY)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [available, setAvailable] = useState(true)
const load = useCallback(async () => {
if (!sku) return
setLoading(true)
setError('')
try {
const { data } = await axios.get(`/api/products/${encodeURIComponent(sku)}/packaging`)
setForm(toForm(data))
setAvailable(true)
} catch (loadError) {
setAvailable(false)
setError(loadError?.response?.data?.error || '读取包装信息失败')
} finally {
setLoading(false)
}
}, [sku])
useEffect(() => { load() }, [load])
useEffect(() => { setNotice('') }, [sku])
const loadingPlan = useMemo(() => calcLoading(form), [form])
// 空白输入一律按「未填」处理,不要变成 0 —— 0 和没填在装柜测算里含义完全不同
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const save = async (event) => {
event.preventDefault()
if (!sku) return
setSaving(true)
setError('')
setNotice('')
const payload = {}
FIELDS.forEach(field => {
const raw = form[field.key]
payload[field.key] = raw === '' ? null : Number(raw)
})
payload.packaging_material = form.packaging_material.trim() || null
payload.notes = form.notes.trim() || null
try {
const { data } = await axios.put(`/api/products/${encodeURIComponent(sku)}/packaging`, payload)
setForm(toForm(data.packaging))
setNotice('包装信息已保存')
if (onSaved) onSaved(data.packaging)
} catch (saveError) {
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
if (loading) return <p className="pkg-loading">读取包装信息…</p>
if (!available) {
return (
<div className="pkg-unavailable">
<p>{error}</p>
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 重试
</button>
</div>
)
}
return (
<form className="pkg-panel" onSubmit={save}>
<div className="pkg-section">
<h4 className="pkg-section-title"><PackageIcon size={14} /> 装箱结构</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'pack').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="1" inputMode="numeric"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">外箱尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'carton').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">单品尺寸与重量</h4>
<div className="pkg-grid">
{FIELDS.filter(field => field.group === 'unit').map(field => (
<label className="pkg-field" key={field.key}>
<span>{field.label}</span>
<span className="pkg-input">
<input
type="number" min="0" step="0.1" inputMode="decimal"
value={form[field.key]} disabled={readOnly}
onChange={event => set(field.key, event.target.value)}
/>
<em>{field.unit}</em>
</span>
</label>
))}
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">包装与备注</h4>
<div className="pkg-grid">
<label className="pkg-field pkg-field-wide">
<span>包装材质</span>
<input
type="text" value={form.packaging_material} disabled={readOnly}
placeholder="例如:彩盒 + 五层瓦楞纸箱"
onChange={event => set('packaging_material', event.target.value)}
/>
</label>
<label className="pkg-field pkg-field-wide">
<span>包装备注</span>
<input
type="text" value={form.notes} disabled={readOnly}
placeholder="唛头要求、客户指定箱规等"
onChange={event => set('notes', event.target.value)}
/>
</label>
</div>
</div>
<div className="pkg-section">
<h4 className="pkg-section-title">装柜测算</h4>
{!loadingPlan ? (
<p className="pkg-loading-hint">填好外箱长宽高即可自动推算各柜型装柜量。</p>
) : (
<>
<p className="pkg-volume">
单箱体积 <b>{loadingPlan.cartonVolume.toFixed(4)}</b> m³
</p>
<div className="pkg-container-grid">
{loadingPlan.rows.map((row, index) => (
<div className="pkg-container" key={row.key}>
<span className="pkg-container-name">{row.label}</span>
<span className="pkg-container-main">
<b>{row.cartons.toLocaleString('zh-CN')}</b> 箱
</span>
<span className="pkg-container-sub">
{row.pieces === null ? '装量未知' : `${row.pieces.toLocaleString('zh-CN')} pcs`}
{loadingPlan.totalWeight ? ` · 毛重 ${(loadingPlan.totalWeight[index] / 1000).toFixed(2)} t` : ''}
</span>
</div>
))}
</div>
<p className="pkg-caveat">按柜内容积 28 / 58 / 68 m³ 估算,未计托盘与装载空隙,实际订舱请以货代确认为准。</p>
</>
)}
</div>
{error && <p className="pkg-error">{error}</p>}
{notice && <p className="pkg-notice">{notice}</p>}
{!readOnly && canEdit && (
<div className="pkg-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load} disabled={saving}>
<RefreshIcon size={14} /> 还原
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存包装信息'}
</button>
</div>
)}
</form>
)
}
export default PackagingPanel