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
+46 -16
View File
@@ -1,14 +1,18 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { useAuth, hasPermission } from '../auth'
import { GridIcon, ListIcon } from '../components/Icons'
import { SkeletonCards, SkeletonPanel } from '../components/Skeleton'
import { SearchField } from '../components/SearchSelect'
import { SkuThumb } from '../components/SkuThumb'
/**
* 供应商管理(原生 React 版)
*
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
* 数据走 /api/sku/* 同源接口(SKU 模块已并入后端),禁止硬编码独立端口。
*/
const API = '/api/sku'
@@ -106,6 +110,8 @@ function Kv({ label, value, mono }) {
function SupplierManagement() {
const navigate = useNavigate()
const { user } = useAuth()
const canEdit = hasPermission(user, 'supplier.edit')
const [mode, setMode] = useState('list') // list | detail | form
const [suppliers, setSuppliers] = useState([])
@@ -125,6 +131,8 @@ function SupplierManagement() {
const [detail, setDetail] = useState(null)
const [detailLoading, setDetailLoading] = useState(false)
// 详情页「供货清单」的即时过滤(一家供应商可能供几十上百个货号)
const [productQuery, setProductQuery] = useState('')
const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState(null)
@@ -165,6 +173,7 @@ function SupplierManagement() {
setDetailLoading(true)
setDetail(null)
setNotice('')
setProductQuery('')
try {
const { data } = await axios.get(`${API}/suppliers/${id}`)
setDetail(data)
@@ -246,7 +255,7 @@ function SupplierManagement() {
<h1 className="page-title">供应商管理</h1>
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
</div>
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
{canEdit && <button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>}
</div>
{notice && <div className="notice-inline">{notice}</div>}
@@ -285,7 +294,7 @@ function SupplierManagement() {
</div>
{loading ? (
<div className="loading">加载中…</div>
<SkeletonCards count={8} />
) : suppliers.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏭</div>
@@ -385,6 +394,11 @@ function SupplierManagement() {
const s = detail?.supplier
const products = detail?.products || []
const contracts = detail?.contracts || []
const pk = productQuery.trim().toLowerCase()
const visibleProducts = pk
? products.filter((p) => [p.display_sku, p.sku, p.group_name, p.name, p.spec]
.filter(Boolean).join(' ').toLowerCase().includes(pk))
: products
return (
<div className="page-container">
@@ -401,14 +415,16 @@ function SupplierManagement() {
</div>
<div className="header-actions">
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
{s && canEdit && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
</div>
</div>
{notice && <div className="notice-inline">{notice}</div>}
{detailLoading || !s ? (
<div className="loading">加载中…</div>
<div className="card">
<SkeletonPanel lines={4} />
</div>
) : (
<>
<div className="card">
@@ -431,12 +447,26 @@ function SupplierManagement() {
<div className="card">
<div className="card-header">
<h2>供货清单</h2>
<span className="secondary">{products.length} 条</span>
<span className="secondary">
{productQuery.trim() ? `${visibleProducts.length} / ${products.length} 条` : `${products.length} 条`}
</span>
</div>
{products.length === 0 ? (
{products.length > 1 && (
<div className="panel-search-row">
<SearchField
size="sm"
value={productQuery}
onChange={setProductQuery}
placeholder="搜货号 / 所属产品 / 规格…"
/>
</div>
)}
{visibleProducts.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 12px' }}>
<div className="empty-state-hint">
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
{products.length === 0
? '还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。'
: '没有匹配的货号,换个关键词试试。'}
</div>
</div>
) : (
@@ -453,12 +483,10 @@ function SupplierManagement() {
</tr>
</thead>
<tbody>
{products.map((p) => (
{visibleProducts.map((p) => (
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
<td>
{p.thumb
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
: <span className="thumb thumb-empty" />}
<SkuThumb sku={p.sku} filename={p.thumb} size={34} />
</td>
<td className="mono">{p.display_sku || p.sku}</td>
<td>{p.group_name || p.name || '—'}</td>
@@ -578,10 +606,12 @@ function SupplierManagement() {
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
{editingId && (
{canEdit && (
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
)}
{editingId && canEdit && (
<button
className="btn btn-danger"
onClick={() => removeSupplier(editingId, editingName)}