本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
97 lines
3.6 KiB
React
97 lines
3.6 KiB
React
import React from 'react'
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* 骨架屏
|
|
*
|
|
* 为什么要有它:接口回来之前,页面原本只显示一句「正在载入…」。
|
|
* 用户看到的是「什么都没有」+ 一行字,主观上就是把整个加载时间都算成了等待。
|
|
* 换成与真实内容同构的灰色骨架后,视线先落在结构上,感知到的等待会短很多
|
|
* ——这不是玄学,是布局稳定(不会突然跳一下)带来的直接体感差别。
|
|
*
|
|
* 设计原则:
|
|
* · 骨架的行高、列宽必须**贴近真实内容**,否则加载完成时页面会大幅跳动,
|
|
* 那比显示一句「正在载入…」还难受。所以宁可少画几行,也不要画得不像。
|
|
* · 纯静态 + 透明度呼吸动画,不用渐变扫光(工业风不需要花活,且省 CPU)。
|
|
* · 尊重「减少动态效果」设置,开了就静止。
|
|
* --------------------------------------------------------------------------- */
|
|
|
|
/** 骨架基础块。宽高直接给数字(px)或字符串('60%')。 */
|
|
export default function Skeleton({ width = '100%', height = 12, radius = 4, className = '', style }) {
|
|
return (
|
|
<span
|
|
className={`skeleton-block ${className}`.trim()}
|
|
style={{ width, height, borderRadius: radius, ...style }}
|
|
aria-hidden="true"
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 表格骨架:必须放在 <tbody> 里。
|
|
* widths 传每列的相对宽度(数字数组),不传就均分。
|
|
* 第一列默认画长一点,因为表格第一列通常是名称/编号。
|
|
*/
|
|
export function SkeletonTableRows({ rows = 6, widths = null, colSpan = 1 }) {
|
|
const cells = widths || [2.2, 1, 1, 1]
|
|
const total = cells.reduce((sum, n) => sum + n, 0)
|
|
return Array.from({ length: rows }).map((_, rowIndex) => (
|
|
<tr key={rowIndex} className="skeleton-row" aria-hidden="true">
|
|
{colSpan > 1 ? (
|
|
<td colSpan={colSpan} style={{ padding: '12px 14px' }}>
|
|
<span className="skeleton-inline">
|
|
{cells.map((weight, cellIndex) => (
|
|
<Skeleton
|
|
key={cellIndex}
|
|
width={`${((weight / total) * 100).toFixed(2)}%`}
|
|
height={12}
|
|
/>
|
|
))}
|
|
</span>
|
|
</td>
|
|
) : (
|
|
cells.map((weight, cellIndex) => (
|
|
<td key={cellIndex} style={{ padding: '12px 14px' }}>
|
|
<Skeleton width={`${Math.min(100, (weight / Math.max(...cells)) * 100).toFixed(0)}%`} height={12} />
|
|
</td>
|
|
))
|
|
)}
|
|
</tr>
|
|
))
|
|
}
|
|
|
|
/**
|
|
* 卡片网格骨架:给工作台那种「一排指标卡」用。
|
|
* 数量与真实卡片一致,避免加载完成时整页重排。
|
|
*/
|
|
export function SkeletonCards({ count = 4, height = 76 }) {
|
|
return (
|
|
<div className="skeleton-cards" aria-hidden="true">
|
|
{Array.from({ length: count }).map((_, index) => (
|
|
<div key={index} className="skeleton-card">
|
|
<Skeleton width="46%" height={11} />
|
|
<Skeleton width="68%" height={22} style={{ marginTop: 10 }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 面板骨架:给「一张面板里几行文本」用。
|
|
*/
|
|
export function SkeletonPanel({ lines = 3, title = true }) {
|
|
return (
|
|
<div className="skeleton-panel" aria-hidden="true">
|
|
{title && <Skeleton width="30%" height={14} style={{ marginBottom: 14 }} />}
|
|
{Array.from({ length: lines }).map((_, index) => (
|
|
<Skeleton
|
|
key={index}
|
|
width={index === lines - 1 ? '55%' : '100%'}
|
|
height={12}
|
|
style={{ marginBottom: 10 }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|