图:

注:
小米相机文档功能 拍下 通过 设置好 NasCab 传到 指定电脑指定目录 两把图片分为10张一组(因为豆包Ai 适图功能只能一次10张 ) 能过豆包Ai 把图片 转成json 数据 (提示词:转成json 格式数据,不稳定就给它参考数据)

转出来示例数据:
python
[
{
"单据编号": "PXX-20251111-xxx",
"客户名称": "bpc",
"发货仓库": "kp仓库",
"经手人": "张三",
"录单时间": "2025-11-11 17:56:46",
"商品明细": [
{
"行号": 1,
"商品名称": "铭瑄MS-挑战者 H610M-R D4",
"数量": 1,
"单价": 335,
"金额": 335,
"备注": null
}
],
"合计数量": 1,
"总金额": 335
},
{
"单据编号": "PXX-20251126-xxx",
"客户名称": "bpc",
"发货仓库": "仓库",
"经手人": "张三",
"录单时间": "2025-11-26 15:09:30",
"商品明细": [
{
"行号": 1,
"商品名称": "西数SN7100 500G m.2",
"数量": 1,
"单价": 480,
"金额": 480,
"备注": null
},
{
"行号": 2,
"商品名称": "航嘉GS600 额定500W",
"数量": 1,
"单价": 180,
"金额": 180,
"备注": null
},
{
"行号": 3,
"商品名称": "阿斯加特金伦加TUF 3600 32G(16*2) C18 黑橙甲",
"数量": 1,
"单价": 980,
"金额": 980,
"备注": null
},
{
"行号": 4,
"商品名称": "铭瑄H311M-VH M.2",
"数量": 1,
"单价": 335,
"金额": 335,
"备注": null
}
],
"合计数量": 4,
"总金额": 1975
},
{
"单据编号": "PXX-20251208-xxx",
"客户名称": "开平bpc",
"发货仓库": "xx仓库",
"经手人": "李四",
"录单时间": "2025-12-08 16:58:06",
"商品明细": [
{
"行号": 1,
"商品名称": "昂达256G 固态",
"数量": 1,
"单价": 205,
"金额": 205,
"备注": null
},
{
"行号": 2,
"商品名称": "水星SG105C 千兆交换机",
"数量": 1,
"单价": 30,
"金额": 30,
"备注": null
},
{
"行号": 3,
"商品名称": "金刚之星 擎云3.0中箱",
"数量": 1,
"单价": 70,
"金额": 70,
"备注": null
}
],
"合计数量": 3,
"总金额": 305
}
]
销售单据数据收集工具 - 使用说明
一、系统概述
1.1 功能简介
本工具是一个专门用于收集、管理和分析销售单据数据的Web应用程序。支持数据导入、搜索、统计分析和导出功能。
1.2 主要特点
-
✅ 可视化界面:现代化UI设计,操作直观
-
✅ 数据管理:支持JSON格式数据导入导出
-
✅ 智能搜索:多维度搜索功能
-
✅ 统计分析:丰富的图表和统计报表
-
✅ 数据安全:关键操作需要密码验证
-
✅ 本地存储:数据自动保存到浏览器本地
二、核心功能说明
2.1 数据导入功能
2.1.1 支持两种导入方式:
-
文件导入:拖拽或选择JSON文件
-
文本导入:直接粘贴JSON数据
2.1.2 数据格式要求:
python
[
{
"单据编号": "PXX-20250830-01139",
"客户名称": "bpc",
"发货仓库": "kpck",
"经手人": "xx",
"录单时间": "2025-08-30",
"商品明细": [
{
"行号": 1,
"商品名称": "航嘉EC0650 额定650 黑色",
"数量": 1,
"单价": 225,
"金额": 225,
"备注": null
}
],
"合计数量": 1,
"总金额": 225
}
]
2.2 搜索功能
2.2.1 搜索范围:
-
🔍 全部字段:在所有字段中搜索
-
📄 单据编号:精确搜索单据编号
-
👥 客户名称:按客户名称搜索
-
🏢 发货仓库:按仓库搜索
-
👤 经手人:按经手人搜索
-
📅 录单时间:按日期搜索
-
📦 商品名称:在商品明细中搜索
2.2.2 搜索技巧:
-
支持模糊搜索
-
不区分大小写
-
实时搜索(输入时自动搜索)
2.3 统计分析功能
2.3.1 核心指标:
-
📄 单据总数、商品总数
-
💰 总金额、平均金额、最高金额
-
👥 客户数、仓库数、经手人数
-
🎯 商品种类数
2.3.2 时间统计:
-
📅 按日统计
-
📆 按周统计
-
📊 按月统计
-
支持自定义日期范围
2.3.3 详细分析:
-
按客户统计:客户销售额排行榜
-
按仓库统计:各仓库业务量对比
-
按经手人统计:员工业绩分析
-
按商品统计:商品销量排行榜
2.4 数据管理功能
2.4.1 单据管理:
-
📋 查看单据列表
-
📄 查看单据详情
-
🗑️ 删除单据(需确认)
2.4.2 数据安全:
-
导出数据 :需要密码 123
-
重置数据 :需要密码 123
-
删除操作需要二次确认
三、操作指南
3.1 基础操作流程
3.1.1 首次使用:
-
点击"导入单据"按钮
-
选择导入方式(文件或文本)
-
验证数据格式
-
确认导入
3.1.2 查看单据:
-
在左侧列表选择单据
-
右侧显示详细信息
-
包括商品明细和合计
3.1.3 搜索单据:
-
点击"搜索"按钮打开搜索栏
-
输入搜索关键词
-
选择搜索字段(可选)
-
查看搜索结果
3.2 统计分析操作
3.2.1 查看统计:
-
点击"统计面板"按钮
-
查看核心指标
-
展开详细统计(可选)
3.2.2 时间分析:
-
设置开始和结束日期
-
选择统计周期(日/周/月)
-
点击"筛选"按钮
-
查看图表和数据表格
3.3 数据维护
3.3.1 导出数据:
-
点击"导出数据"按钮
-
输入密码:123
-
系统自动下载JSON文件
3.3.2 重置系统:
-
点击"重置数据"按钮
-
输入密码:123
-
确认重置操作
-
系统将清空所有数据
四、技术特性
4.1 数据存储
-
存储位置:浏览器LocalStorage
-
数据格式:标准JSON
-
自动保存:修改后自动保存
-
数据恢复:刷新页面数据不丢失
4.2 性能特点
-
⚡ 快速响应:所有操作即时反馈
-
📱 响应式设计:支持手机和电脑
-
🔄 实时更新:数据变化立即反映
-
🎨 动画效果:平滑的界面过渡
4.3 兼容性
-
浏览器:Chrome、Firefox、Edge、Safari
-
设备:电脑、平板、手机
-
分辨率:适配各种屏幕尺寸
五、使用注意事项
5.1 数据安全
-
密码保护:重要操作需要密码验证
-
数据备份:定期导出数据备份
-
操作确认:删除和重置操作需要确认
5.2 数据格式
-
必需字段:所有标红字段必须填写
-
数据验证:导入时会自动验证格式
-
重复检查:单据编号不能重复
5.3 浏览器要求
-
启用JavaScript:必须启用JavaScript
-
支持LocalStorage:浏览器需支持HTML5
-
屏幕尺寸:建议最小宽度320px
六、故障排除
6.1 常见问题
6.1.1 导入失败:
-
✅ 检查JSON格式是否正确
-
✅ 确保必需字段齐全
-
✅ 验证数据是否符合规范
6.1.2 搜索无结果:
-
✅ 确认搜索关键词正确
-
✅ 检查搜索字段选择
-
✅ 确保有相关数据
6.1.3 统计不显示:
-
✅ 确认已导入数据
-
✅ 检查日期范围设置
-
✅ 刷新统计面板
6.2 数据恢复
-
自动恢复:数据自动保存在浏览器
-
手动恢复:从导出的JSON文件重新导入
-
紧急恢复:联系技术支持
七、使用建议
7.1 最佳实践
-
定期备份:每周导出数据备份
-
分类管理:按客户或时间分类单据
-
统计分析:每月进行销售数据分析
-
数据清理:定期清理无效数据
7.2 效率技巧
-
快捷键:善用Tab键导航
-
批量操作:使用JSON批量导入
-
快速搜索:使用特定字段搜索
-
模板使用:保存常用JSON模板
八、版本信息
8.1 当前版本
-
版本号:v1.0
-
更新日期:2026年
-
主要功能:单据管理、统计分析、数据导入导出
8.2 更新计划
- 导出Excel格式
- 打印功能 PDF
- json数据转换
- 增加后端 FastAPI
代码:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>销售单据数据收集工具 - 搜索版</title>
<style>
:root {
--primary: #3498db;
--primary-dark: #2980b9;
--secondary: #2ecc71;
--danger: #e74c3c;
--warning: #f39c12;
--info: #17a2b8;
--dark: #2c3e50;
--light: #ecf0f1;
--gray: #95a5a6;
--border: #e0e7ee;
--shadow: 0 4px 12px rgba(0,0,0,0.08);
--radius: 10px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #f5f7fa 0%, #e4eef5 100%);
color: #333;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background: white;
border-radius: var(--radius);
padding: 20px 30px;
margin-bottom: 20px;
box-shadow: var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
h1 {
color: var(--dark);
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.logo {
background: var(--primary);
color: white;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.btn-secondary {
background: var(--secondary);
color: white;
}
.btn-secondary:hover {
background: #27ae60;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
border: 2px solid var(--primary);
color: var(--primary);
}
.btn-outline:hover {
background: var(--primary);
color: white;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
}
/* 搜索区域样式 */
.search-container {
background: white;
border-radius: var(--radius);
padding: 15px 20px;
margin-bottom: 15px;
box-shadow: var(--shadow);
display: none;
}
.search-container.active {
display: block;
animation: fadeIn 0.3s ease;
}
.search-box {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 10px 15px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.15);
}
.search-select {
padding: 10px 15px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.95rem;
background: white;
cursor: pointer;
min-width: 150px;
}
.search-actions {
display: flex;
gap: 8px;
align-items: center;
}
.search-info {
font-size: 0.85rem;
color: var(--gray);
padding: 5px 10px;
background: var(--light);
border-radius: 6px;
display: none;
}
.search-info.active {
display: inline-block;
}
.search-info strong {
color: var(--primary);
font-weight: 600;
}
/* 统计面板样式 */
.stats-panel {
background: white;
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
box-shadow: var(--shadow);
display: none;
}
.stats-panel.active {
display: block;
animation: fadeIn 0.3s ease;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--primary);
}
.stat-card.secondary::before { background: var(--secondary); }
.stat-card.danger::before { background: var(--danger); }
.stat-card.warning::before { background: var(--warning); }
.stat-card.info::before { background: var(--info); }
.stat-label {
font-size: 0.85rem;
color: var(--gray);
font-weight: 500;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--dark);
line-height: 1.2;
}
.stat-unit {
font-size: 0.9rem;
color: var(--gray);
font-weight: 500;
margin-left: 4px;
}
/* 时间统计区域 */
.time-stats-section {
background: #f8fafc;
border-radius: 8px;
padding: 20px;
border: 1px solid var(--border);
margin-top: 15px;
}
.time-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
align-items: center;
}
.time-controls input[type="date"] {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
}
.time-controls select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
background: white;
}
.time-chart {
margin-top: 15px;
padding: 15px;
background: white;
border-radius: 6px;
border: 1px solid var(--border);
}
.chart-bar {
display: flex;
align-items: flex-end;
gap: 8px;
height: 150px;
margin: 10px 0;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.bar-item {
flex: 1;
background: var(--primary);
border-radius: 4px 4px 0 0;
min-height: 5px;
position: relative;
transition: all 0.3s ease;
cursor: pointer;
}
.bar-item:hover {
background: var(--primary-dark);
transform: scaleY(1.05);
}
.bar-item .bar-label {
position: absolute;
bottom: -25px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: var(--gray);
white-space: nowrap;
}
.bar-item .bar-value {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
font-weight: 600;
color: var(--dark);
white-space: nowrap;
}
.chart-empty {
text-align: center;
padding: 30px;
color: var(--gray);
font-style: italic;
}
/* 详情统计表格 */
.detail-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 15px;
}
@media (max-width: 768px) {
.detail-stats {
grid-template-columns: 1fr;
}
}
.detail-table-card {
background: #f8fafc;
border-radius: 8px;
padding: 15px;
border: 1px solid var(--border);
}
.detail-table-card h4 {
margin-bottom: 12px;
color: var(--dark);
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.detail-table th {
background: white;
padding: 8px;
text-align: left;
font-weight: 600;
color: var(--dark);
border-bottom: 2px solid var(--border);
}
.detail-table td {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.detail-table tr:hover {
background: rgba(52, 152, 219, 0.05);
}
.progress-bar {
width: 100%;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-top: 6px;
}
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s ease;
}
/* 主内容区域 */
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 900px) {
.main-content {
grid-template-columns: 1fr;
}
}
.card {
background: white;
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
overflow: hidden;
}
.card-header {
padding-bottom: 15px;
border-bottom: 1px solid var(--border);
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--dark);
display: flex;
align-items: center;
gap: 8px;
}
.badge {
background: var(--light);
color: var(--dark);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
/* 单据列表样式 */
.invoice-list {
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.invoice-item {
padding: 15px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.invoice-item:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.15);
transform: translateY(-2px);
}
.invoice-item.active {
border-color: var(--primary);
background: rgba(52, 152, 219, 0.05);
border-left: 4px solid var(--primary);
}
.invoice-item.highlight {
background: rgba(255, 193, 7, 0.1);
border-color: var(--warning);
}
.invoice-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.invoice-number {
font-weight: 600;
color: var(--dark);
}
.invoice-date {
color: var(--gray);
font-size: 0.85rem;
}
.invoice-details {
font-size: 0.9rem;
color: #555;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.invoice-details span {
display: flex;
align-items: center;
gap: 5px;
}
/* 详情区域样式 */
.detail-container {
min-height: 350px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--gray);
gap: 15px;
padding: 40px;
text-align: center;
}
.empty-state svg {
width: 60px;
height: 60px;
opacity: 0.3;
}
.detail-view {
display: none;
}
.detail-view.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.detail-section {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px dashed var(--border);
}
.detail-section h3 {
margin-bottom: 12px;
color: var(--dark);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
font-size: 0.85rem;
color: var(--gray);
font-weight: 500;
}
.detail-value {
font-size: 1rem;
color: var(--dark);
font-weight: 500;
}
/* 商品明细表格 */
.products-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
border-radius: 8px;
overflow: hidden;
}
.products-table th {
background: var(--primary);
color: white;
padding: 12px 15px;
text-align: left;
font-weight: 500;
}
.products-table td {
padding: 10px 15px;
border-bottom: 1px solid var(--border);
}
.products-table tr:last-child td {
border-bottom: none;
}
.products-table tr:hover {
background: rgba(52, 152, 219, 0.05);
}
.total-row {
background: rgba(52, 152, 219, 0.1);
font-weight: 600;
}
/* 导入区域样式 */
.import-container {
background: white;
border-radius: var(--radius);
padding: 25px;
box-shadow: var(--shadow);
}
.import-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 30px;
text-align: center;
transition: all 0.3s;
background: #f8fafc;
margin-bottom: 20px;
}
.import-zone.dragover {
border-color: var(--primary);
background: rgba(52, 152, 219, 0.05);
transform: scale(1.02);
}
.import-zone svg {
width: 50px;
height: 50px;
color: var(--gray);
margin-bottom: 15px;
}
.file-input-wrapper {
position: relative;
display: inline-block;
margin-top: 15px;
}
.file-input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.file-input-label {
display: inline-block;
padding: 10px 20px;
background: var(--primary);
color: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.file-input-label:hover {
background: var(--primary-dark);
transform: translateY(-2px);
}
.text-input-area {
margin-top: 20px;
}
.text-input-area label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--dark);
}
textarea {
width: 100%;
min-height: 150px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.15);
}
.import-actions {
display: flex;
gap: 12px;
margin-top: 15px;
justify-content: flex-end;
}
.preview-section {
margin-top: 20px;
padding: 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid var(--border);
display: none;
}
.preview-section.active {
display: block;
animation: fadeIn 0.3s ease;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.preview-content {
max-height: 300px;
overflow-y: auto;
background: white;
padding: 15px;
border-radius: 6px;
border: 1px solid var(--border);
}
.preview-content pre {
margin: 0;
font-size: 0.85rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.validation-result {
margin-top: 15px;
padding: 12px;
border-radius: 6px;
font-size: 0.9rem;
display: none;
}
.validation-result.success {
background: rgba(46, 204, 113, 0.1);
border: 1px solid var(--secondary);
color: #27ae60;
display: block;
}
.validation-result.error {
background: rgba(231, 76, 60, 0.1);
border: 1px solid var(--danger);
color: #c0392b;
display: block;
}
.hidden {
display: none !important;
}
/* 提示框 */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--dark);
color: white;
padding: 12px 25px;
border-radius: 8px;
box-shadow: var(--shadow);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 1000;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
background: var(--secondary);
}
.toast.error {
background: var(--danger);
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 示例JSON */
.example-json {
margin-top: 15px;
padding: 15px;
background: #f0f8ff;
border-radius: 6px;
border: 1px solid #d0e8ff;
font-size: 0.85rem;
}
.example-json summary {
cursor: pointer;
font-weight: 500;
color: var(--primary);
margin-bottom: 8px;
}
.example-json pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 0.8rem;
color: #555;
}
/* 统计详情展开/收起 */
.toggle-stats {
background: var(--light);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 15px;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.toggle-stats:hover {
background: #e8f4f8;
border-color: var(--primary);
}
.toggle-stats.expanded {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.stats-details {
display: none;
margin-top: 10px;
animation: fadeIn 0.3s ease;
}
.stats-details.active {
display: block;
}
.empty-stats {
text-align: center;
padding: 20px;
color: var(--gray);
font-style: italic;
}
/* 响应式调整 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.detail-stats {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
align-items: flex-start;
}
.actions {
width: 100%;
justify-content: flex-start;
}
.time-controls {
flex-direction: column;
align-items: stretch;
}
.time-controls input,
.time-controls select {
width: 100%;
}
.search-box {
flex-direction: column;
}
.search-input {
width: 100%;
}
.search-actions {
width: 100%;
justify-content: space-between;
}
.search-select {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1><div class="logo">SD</div>销售单据数据收集工具</h1>
<div class="actions">
<button class="btn btn-primary" id="importBtn">
<span>📥</span> 导入单据
</button>
<button class="btn btn-secondary" id="exportBtn">
<span>📤</span> 导出数据
</button>
<button class="btn btn-outline" id="toggleStatsBtn">
<span>📊</span> 统计面板
</button>
<button class="btn btn-outline" id="resetBtn">
重置数据
</button>
</div>
</header>
<!-- 搜索区域 -->
<div class="search-container" id="searchContainer">
<div class="search-box">
<input type="text" id="searchInput" class="search-input" placeholder="🔍 搜索单据编号、客户名称、仓库、经手人、商品名称...">
<select id="searchField" class="search-select">
<option value="all">全部字段</option>
<option value="单据编号">单据编号</option>
<option value="客户名称">客户名称</option>
<option value="发货仓库">发货仓库</option>
<option value="经手人">经手人</option>
<option value="录单时间">录单时间</option>
<option value="商品名称">商品名称</option>
</select>
<div class="search-actions">
<button class="btn btn-primary" id="searchBtn">搜索</button>
<button class="btn btn-outline" id="clearSearchBtn">清空</button>
<span class="search-info" id="searchInfo"></span>
</div>
</div>
</div>
<!-- 统计面板 -->
<div class="stats-panel" id="statsPanel">
<div class="stats-header">
<h2 class="card-title">📈 数据统计分析</h2>
<div class="actions">
<button class="btn btn-sm btn-outline" id="refreshStatsBtn">刷新统计</button>
</div>
</div>
<!-- 核心指标卡片 -->
<div class="stats-grid" id="coreStats">
<!-- 动态生成 -->
</div>
<!-- 时间统计区域 -->
<div class="time-stats-section" id="timeStatsSection">
<h4>📅 按时间统计</h4>
<div class="time-controls">
<input type="date" id="startDate" placeholder="开始日期">
<input type="date" id="endDate" placeholder="结束日期">
<select id="timeGroup">
<option value="day">按日统计</option>
<option value="week">按周统计</option>
<option value="month">按月统计</option>
</select>
<button class="btn btn-sm btn-primary" id="filterTimeBtn">筛选</button>
<button class="btn btn-sm btn-outline" id="resetTimeBtn">重置</button>
</div>
<div id="timeStatsResult">
<div class="empty-stats">请选择日期范围查看统计</div>
</div>
<div class="time-chart" id="timeChart">
<div class="chart-empty">暂无图表数据</div>
</div>
</div>
<!-- 详情统计展开/收起 -->
<div class="toggle-stats" id="toggleStatsDetails">
<span>📊 查看详细统计分析</span>
<span id="toggleIcon">▼</span>
</div>
<!-- 详细统计内容 -->
<div class="stats-details" id="statsDetails">
<div class="detail-stats">
<!-- 按客户统计 -->
<div class="detail-table-card">
<h4>👥 按客户统计</h4>
<div id="customerStats"></div>
</div>
<!-- 按仓库统计 -->
<div class="detail-table-card">
<h4>🏢 按仓库统计</h4>
<div id="warehouseStats"></div>
</div>
<!-- 按经手人统计 -->
<div class="detail-table-card">
<h4>👤 按经手人统计</h4>
<div id="handlerStats"></div>
</div>
<!-- 按商品统计 -->
<div class="detail-table-card">
<h4>📦 按商品统计</h4>
<div id="productStats"></div>
</div>
</div>
</div>
</div>
<div class="main-content">
<!-- 单据列表 -->
<div class="card">
<div class="card-header">
<div class="card-title">📋 单据列表</div>
<div class="actions">
<button class="btn btn-sm btn-outline" id="toggleSearchBtn">🔍 搜索</button>
<div class="badge" id="invoiceCount">0 单</div>
</div>
</div>
<div class="invoice-list" id="invoiceList">
<!-- 单据列表将通过JS动态生成 -->
</div>
</div>
<!-- 单据详情 -->
<div class="card">
<div class="card-header">
<div class="card-title">📄 单据详情</div>
<div class="actions">
<button class="btn btn-outline btn-sm hidden" id="deleteBtn">删除</button>
</div>
</div>
<div class="detail-container">
<div class="empty-state" id="emptyState">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
<line x1="9" y1="13" x2="15" y2="13"></line>
<line x1="9" y1="17" x2="11" y2="17"></line>
</svg>
<p>请选择左侧单据查看详情</p>
</div>
<div class="detail-view" id="detailView">
<!-- 详情内容将通过JS动态生成 -->
</div>
</div>
</div>
</div>
<!-- 导入区域 -->
<div class="import-container hidden" id="importContainer">
<div class="card-header">
<div class="card-title">📥 导入单据数据</div>
<div class="actions">
<button class="btn btn-danger" id="cancelImportBtn">取消</button>
</div>
</div>
<div class="import-zone" id="dropZone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<h3>拖拽JSON文件到此处</h3>
<p>或点击下方按钮选择文件</p>
<div class="file-input-wrapper">
<input type="file" id="fileInput" class="file-input" accept=".json">
<label for="fileInput" class="file-input-label">选择JSON文件</label>
</div>
</div>
<div class="text-input-area">
<label for="jsonTextarea">或者直接粘贴JSON数据:</label>
<textarea id="jsonTextarea" placeholder='[
{
"单据编号": "PXX-20250830-01139",
"客户名称": "bpc",
"发货仓库": "xx仓库",
"经手人": "张三",
"录单时间": "2025-08-30",
"商品明细": [
{
"行号": 1,
"商品名称": "航嘉EC0650 额定650 黑色",
"数量": 1,
"单价": 225,
"金额": 225,
"备注": null
}
],
"合计数量": 1,
"总金额": 225
}
]'></textarea>
<details class="example-json">
<summary>查看JSON格式示例</summary>
<pre>[
{
"单据编号": "PXX-20250830-01139",
"客户名称": "bpc",
"发货仓库": "xx仓库",
"经手人": "张三",
"录单时间": "2025-08-30",
"商品明细": [
{
"行号": 1,
"商品名称": "航嘉EC0650 额定650 黑色",
"数量": 1,
"单价": 225,
"金额": 225,
"备注": null
}
],
"合计数量": 1,
"总金额": 225
}
]</pre>
</details>
<div class="import-actions">
<button class="btn btn-outline" id="clearJsonBtn">清空</button>
<button class="btn btn-primary" id="parseJsonBtn">解析数据</button>
</div>
<div class="validation-result" id="validationResult"></div>
<div class="preview-section" id="previewSection">
<div class="preview-header">
<h4>📋 导入预览</h4>
<div class="stats" id="previewStats"></div>
</div>
<div class="preview-content">
<pre id="previewContent"></pre>
</div>
<div class="import-actions">
<button class="btn btn-secondary" id="confirmImportBtn">确认导入</button>
</div>
</div>
</div>
</div>
</div>
<!-- 提示框 -->
<div class="toast" id="toast"></div>
<script>
// 应用状态
let appState = {
invoices: [],
selectedInvoice: null,
parsedData: null,
isImporting: false,
statsExpanded: false,
filteredTimeData: [],
searchResults: [],
isSearching: false,
searchQuery: '',
searchField: 'all'
};
// DOM 元素
const elements = {
// 搜索相关
searchContainer: document.getElementById('searchContainer'),
searchInput: document.getElementById('searchInput'),
searchField: document.getElementById('searchField'),
searchBtn: document.getElementById('searchBtn'),
clearSearchBtn: document.getElementById('clearSearchBtn'),
searchInfo: document.getElementById('searchInfo'),
toggleSearchBtn: document.getElementById('toggleSearchBtn'),
statsPanel: document.getElementById('statsPanel'),
toggleStatsBtn: document.getElementById('toggleStatsBtn'),
refreshStatsBtn: document.getElementById('refreshStatsBtn'),
coreStats: document.getElementById('coreStats'),
toggleStatsDetails: document.getElementById('toggleStatsDetails'),
statsDetails: document.getElementById('statsDetails'),
toggleIcon: document.getElementById('toggleIcon'),
customerStats: document.getElementById('customerStats'),
warehouseStats: document.getElementById('warehouseStats'),
handlerStats: document.getElementById('handlerStats'),
productStats: document.getElementById('productStats'),
// 时间统计相关
timeStatsSection: document.getElementById('timeStatsSection'),
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
timeGroup: document.getElementById('timeGroup'),
filterTimeBtn: document.getElementById('filterTimeBtn'),
resetTimeBtn: document.getElementById('resetTimeBtn'),
timeStatsResult: document.getElementById('timeStatsResult'),
timeChart: document.getElementById('timeChart'),
invoiceList: document.getElementById('invoiceList'),
invoiceCount: document.getElementById('invoiceCount'),
emptyState: document.getElementById('emptyState'),
detailView: document.getElementById('detailView'),
importBtn: document.getElementById('importBtn'),
exportBtn: document.getElementById('exportBtn'),
resetBtn: document.getElementById('resetBtn'),
deleteBtn: document.getElementById('deleteBtn'),
importContainer: document.getElementById('importContainer'),
cancelImportBtn: document.getElementById('cancelImportBtn'),
dropZone: document.getElementById('dropZone'),
fileInput: document.getElementById('fileInput'),
jsonTextarea: document.getElementById('jsonTextarea'),
clearJsonBtn: document.getElementById('clearJsonBtn'),
parseJsonBtn: document.getElementById('parseJsonBtn'),
validationResult: document.getElementById('validationResult'),
previewSection: document.getElementById('previewSection'),
previewContent: document.getElementById('previewContent'),
previewStats: document.getElementById('previewStats'),
confirmImportBtn: document.getElementById('confirmImportBtn'),
toast: document.getElementById('toast')
};
// 密码验证函数
function promptPassword(action) {
const password = prompt(`请输入密码以${action === 'export' ? '导出' : '重置'}数据:`);
if (password === null) {
// 用户点击取消
return;
}
if (password === '123') {
// 密码正确,执行相应操作
if (action === 'export') {
exportData();
} else if (action === 'reset') {
resetData();
}
} else {
showToast('密码错误!', 'error');
}
}
// 初始化应用
function initApp() {
// 从localStorage加载数据
const savedData = localStorage.getItem('invoiceData');
if (savedData) {
appState.invoices = JSON.parse(savedData);
}
renderInvoiceList();
updateInvoiceCount();
// 搜索相关事件
elements.toggleSearchBtn.addEventListener('click', toggleSearch);
elements.searchBtn.addEventListener('click', performSearch);
elements.clearSearchBtn.addEventListener('click', clearSearch);
elements.searchInput.addEventListener('input', debounce(performSearch, 300));
elements.searchField.addEventListener('change', () => {
if (elements.searchInput.value.trim()) {
performSearch();
}
});
// 统计相关事件
elements.toggleStatsBtn.addEventListener('click', toggleStatsPanel);
elements.refreshStatsBtn.addEventListener('click', updateStats);
elements.toggleStatsDetails.addEventListener('click', toggleStatsDetails);
// 时间统计事件
elements.filterTimeBtn.addEventListener('click', filterByTime);
elements.resetTimeBtn.addEventListener('click', resetTimeFilter);
// 导入导出事件
elements.importBtn.addEventListener('click', showImportArea);
elements.exportBtn.addEventListener('click', () => promptPassword('export'));
elements.resetBtn.addEventListener('click', () => promptPassword('reset'));
elements.deleteBtn.addEventListener('click', deleteInvoice);
elements.cancelImportBtn.addEventListener('click', hideImportArea);
elements.clearJsonBtn.addEventListener('click', clearJsonInput);
elements.parseJsonBtn.addEventListener('click', parseJsonData);
elements.confirmImportBtn.addEventListener('click', confirmImport);
// 文件上传事件
elements.fileInput.addEventListener('change', handleFileSelect);
// 拖拽事件
elements.dropZone.addEventListener('dragover', handleDragOver);
elements.dropZone.addEventListener('dragleave', handleDragLeave);
elements.dropZone.addEventListener('drop', handleDrop);
// 初始更新统计(如果有数据)
if (appState.invoices.length > 0) {
updateStats();
}
// 设置默认日期范围(最近30天)
setDefaultDateRange();
}
// 防抖函数
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 切换搜索显示
function toggleSearch() {
const isVisible = elements.searchContainer.classList.contains('active');
if (isVisible) {
elements.searchContainer.classList.remove('active');
elements.toggleSearchBtn.innerHTML = '🔍 搜索';
clearSearch(); // 隐藏时清空搜索
} else {
elements.searchContainer.classList.add('active');
elements.toggleSearchBtn.innerHTML = '✕ 关闭搜索';
elements.searchInput.focus();
}
}
// 执行搜索
function performSearch() {
const query = elements.searchInput.value.trim();
const field = elements.searchField.value;
if (!query) {
if (appState.isSearching) {
clearSearch();
}
return;
}
appState.searchQuery = query;
appState.searchField = field;
appState.isSearching = true;
// 执行搜索
const results = appState.invoices.filter(invoice => {
if (field === 'all') {
// 搜索所有字段(包括商品名称)
const matchBasic = (
invoice.单据编号.toLowerCase().includes(query.toLowerCase()) ||
invoice.客户名称.toLowerCase().includes(query.toLowerCase()) ||
invoice.发货仓库.toLowerCase().includes(query.toLowerCase()) ||
invoice.经手人.toLowerCase().includes(query.toLowerCase()) ||
invoice.录单时间.toLowerCase().includes(query.toLowerCase())
);
// 搜索商品名称
const matchProducts = invoice.商品明细.some(prod =>
prod.商品名称.toLowerCase().includes(query.toLowerCase())
);
return matchBasic || matchProducts;
} else if (field === '商品名称') {
// 仅搜索商品名称
return invoice.商品明细.some(prod =>
prod.商品名称.toLowerCase().includes(query.toLowerCase())
);
} else {
// 搜索指定字段
const fieldValue = invoice[field].toString().toLowerCase();
return fieldValue.includes(query.toLowerCase());
}
});
appState.searchResults = results;
renderInvoiceList(results);
updateSearchInfo(results.length);
}
// 清空搜索
function clearSearch() {
elements.searchInput.value = '';
appState.isSearching = false;
appState.searchResults = [];
appState.searchQuery = '';
appState.searchField = 'all';
elements.searchInfo.classList.remove('active');
renderInvoiceList();
updateInvoiceCount();
}
// 更新搜索信息
function updateSearchInfo(count) {
if (appState.isSearching) {
elements.searchInfo.innerHTML = `找到 <strong>${count}</strong> 条结果`;
elements.searchInfo.classList.add('active');
} else {
elements.searchInfo.classList.remove('active');
}
}
// 设置默认日期范围
function setDefaultDateRange() {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
elements.endDate.value = formatDateForInput(today);
elements.startDate.value = formatDateForInput(thirtyDaysAgo);
}
// 格式化日期为输入框格式
function formatDateForInput(date) {
return date.toISOString().split('T')[0];
}
// 切换统计面板显示
function toggleStatsPanel() {
const isHidden = elements.statsPanel.style.display === 'none' || elements.statsPanel.classList.contains('hidden');
if (isHidden) {
elements.statsPanel.style.display = 'block';
elements.statsPanel.classList.add('active');
updateStats();
elements.toggleStatsBtn.innerHTML = '<span>📊</span> 收起统计';
elements.toggleStatsBtn.classList.add('btn-secondary');
elements.toggleStatsBtn.classList.remove('btn-outline');
} else {
elements.statsPanel.style.display = 'none';
elements.statsPanel.classList.remove('active');
elements.toggleStatsBtn.innerHTML = '<span>📊</span> 统计面板';
elements.toggleStatsBtn.classList.remove('btn-secondary');
elements.toggleStatsBtn.classList.add('btn-outline');
}
}
// 切换详细统计展开/收起
function toggleStatsDetails() {
appState.statsExpanded = !appState.statsExpanded;
if (appState.statsExpanded) {
elements.statsDetails.classList.add('active');
elements.toggleStatsDetails.classList.add('expanded');
elements.toggleIcon.textContent = '▲';
elements.toggleStatsDetails.querySelector('span').textContent = '▲ 收起详细统计分析';
updateDetailStats();
} else {
elements.statsDetails.classList.remove('active');
elements.toggleStatsDetails.classList.remove('expanded');
elements.toggleIcon.textContent = '▼';
elements.toggleStatsDetails.querySelector('span').textContent = '📊 查看详细统计分析';
}
}
// 更新统计面板
function updateStats() {
if (appState.invoices.length === 0) {
elements.coreStats.innerHTML = `
<div class="empty-stats">
暂无数据,请导入单据后查看统计
</div>
`;
return;
}
// 计算核心指标
const totalInvoices = appState.invoices.length;
const totalProducts = appState.invoices.reduce((sum, inv) => sum + inv.合计数量, 0);
const totalAmount = appState.invoices.reduce((sum, inv) => sum + inv.总金额, 0);
const avgAmount = totalInvoices > 0 ? totalAmount / totalInvoices : 0;
const maxAmount = Math.max(...appState.invoices.map(inv => inv.总金额));
// 计算客户、仓库、经手人数量
const uniqueCustomers = new Set(appState.invoices.map(inv => inv.客户名称)).size;
const uniqueWarehouses = new Set(appState.invoices.map(inv => inv.发货仓库)).size;
const uniqueHandlers = new Set(appState.invoices.map(inv => inv.经手人)).size;
// 计算商品种类
const allProducts = new Set();
appState.invoices.forEach(inv => {
inv.商品明细.forEach(prod => allProducts.add(prod.商品名称));
});
const uniqueProducts = allProducts.size;
// 计算时间范围
const dates = appState.invoices.map(inv => new Date(inv.录单时间));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const dateRange = `${minDate.toLocaleDateString()} - ${maxDate.toLocaleDateString()}`;
elements.coreStats.innerHTML = `
<div class="stat-card">
<div class="stat-label">📄 单据总数</div>
<div class="stat-value">${totalInvoices}<span class="stat-unit">单</span></div>
</div>
<div class="stat-card secondary">
<div class="stat-label">📦 商品总数</div>
<div class="stat-value">${totalProducts}<span class="stat-unit">件</span></div>
</div>
<div class="stat-card danger">
<div class="stat-label">💰 总金额</div>
<div class="stat-value">¥${totalAmount.toFixed(2)}</div>
</div>
<div class="stat-card warning">
<div class="stat-label">📊 平均单据金额</div>
<div class="stat-value">¥${avgAmount.toFixed(2)}</div>
</div>
<div class="stat-card info">
<div class="stat-label">🎯 最高单据金额</div>
<div class="stat-value">¥${maxAmount.toFixed(2)}</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #e3f2fd 0%, #ffffff 100%);">
<div class="stat-label">👥 客户/仓库/经手人</div>
<div class="stat-value" style="font-size: 1.3rem;">${uniqueCustomers}/${uniqueWarehouses}/${uniqueHandlers}</div>
<div style="font-size: 0.8rem; color: var(--gray); margin-top: 4px;">客户/仓库/经手人</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #f3e5f5 0%, #ffffff 100%);">
<div class="stat-label">🎯 商品种类</div>
<div class="stat-value">${uniqueProducts}<span class="stat-unit">种</span></div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #fff3e0 0%, #ffffff 100%);">
<div class="stat-label">📅 数据时间范围</div>
<div class="stat-value" style="font-size: 1.1rem; line-height: 1.4;">${dateRange}</div>
</div>
`;
}
// 按时间筛选
function filterByTime() {
if (appState.invoices.length === 0) {
showToast('暂无数据', 'error');
return;
}
const startDate = elements.startDate.value;
const endDate = elements.endDate.value;
const groupBy = elements.timeGroup.value;
if (!startDate || !endDate) {
showToast('请选择开始和结束日期', 'error');
return;
}
if (new Date(startDate) > new Date(endDate)) {
showToast('开始日期不能晚于结束日期', 'error');
return;
}
// 筛选数据
const filtered = appState.invoices.filter(inv => {
const invDate = inv.录单时间.split(' ')[0]; // 只取日期部分
return invDate >= startDate && invDate <= endDate;
});
if (filtered.length === 0) {
elements.timeStatsResult.innerHTML = `
<div class="empty-stats">
该时间段内无数据<br>
<small>请选择其他日期范围</small>
</div>
`;
elements.timeChart.innerHTML = '<div class="chart-empty">暂无图表数据</div>';
return;
}
appState.filteredTimeData = filtered;
renderTimeStats(filtered, groupBy);
renderTimeChart(filtered, groupBy);
}
// 重置时间筛选
function resetTimeFilter() {
setDefaultDateRange();
elements.timeStatsResult.innerHTML = '<div class="empty-stats">请选择日期范围查看统计</div>';
elements.timeChart.innerHTML = '<div class="chart-empty">暂无图表数据</div>';
appState.filteredTimeData = [];
}
// 渲染时间统计
function renderTimeStats(data, groupBy) {
const grouped = groupDataByTime(data, groupBy);
let html = `
<table class="detail-table">
<thead>
<tr>
<th>时间周期</th>
<th>单据数</th>
<th>商品数</th>
<th>总金额</th>
<th>平均单据</th>
</tr>
</thead>
<tbody>
`;
const totalData = {
invoices: 0,
products: 0,
amount: 0
};
Object.entries(grouped).forEach(([period, items]) => {
const periodInvoices = items.length;
const periodProducts = items.reduce((sum, inv) => sum + inv.合计数量, 0);
const periodAmount = items.reduce((sum, inv) => sum + inv.总金额, 0);
const periodAvg = periodInvoices > 0 ? periodAmount / periodInvoices : 0;
totalData.invoices += periodInvoices;
totalData.products += periodProducts;
totalData.amount += periodAmount;
html += `
<tr>
<td><strong>${period}</strong></td>
<td>${periodInvoices}</td>
<td>${periodProducts}</td>
<td>¥${periodAmount.toFixed(2)}</td>
<td>¥${periodAvg.toFixed(2)}</td>
</tr>
`;
});
const overallAvg = totalData.invoices > 0 ? totalData.amount / totalData.invoices : 0;
html += `
</tbody>
<tfoot>
<tr style="font-weight: 700; background: var(--light);">
<td>合计</td>
<td>${totalData.invoices}</td>
<td>${totalData.products}</td>
<td>¥${totalData.amount.toFixed(2)}</td>
<td>¥${overallAvg.toFixed(2)}</td>
</tr>
</tfoot>
</table>
<div style="margin-top: 10px; font-size: 0.85rem; color: var(--gray);">
统计周期: ${groupBy === 'day' ? '按日' : groupBy === 'week' ? '按周' : '按月'} |
数据范围: ${data.length} 个单据
</div>
`;
elements.timeStatsResult.innerHTML = html;
}
// 渲染时间图表
function renderTimeChart(data, groupBy) {
const grouped = groupDataByTime(data, groupBy);
const entries = Object.entries(grouped);
if (entries.length === 0) {
elements.timeChart.innerHTML = '<div class="chart-empty">暂无图表数据</div>';
return;
}
// 计算每个周期的总金额
const chartData = entries.map(([period, items]) => ({
period,
amount: items.reduce((sum, inv) => sum + inv.总金额, 0),
count: items.length
}));
// 找到最大值用于缩放
const maxAmount = Math.max(...chartData.map(d => d.amount));
let html = '<div class="chart-bar">';
chartData.forEach(item => {
const height = maxAmount > 0 ? (item.amount / maxAmount * 100) : 0;
html += `
<div class="bar-item" style="height: ${height}%;" title="${item.period}: ¥${item.amount.toFixed(2)} (${item.count}单)">
<div class="bar-value">¥${item.amount.toFixed(0)}</div>
<div class="bar-label">${item.period}</div>
</div>
`;
});
html += '</div>';
html += `
<div style="margin-top: 10px; font-size: 0.8rem; color: var(--gray); text-align: center;">
鼠标悬停查看详细金额 | 共 ${chartData.length} 个周期
</div>
`;
elements.timeChart.innerHTML = html;
}
// 按时间分组数据
function groupDataByTime(data, groupBy) {
const grouped = {};
data.forEach(inv => {
let key;
const date = new Date(inv.录单时间.split(' ')[0]);
if (groupBy === 'day') {
key = inv.录单时间.split(' ')[0]; // YYYY-MM-DD
} else if (groupBy === 'week') {
// 获取周起始日期(周一)
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
const weekStart = new Date(date.setDate(diff));
key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')} (周)`;
} else if (groupBy === 'month') {
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(inv);
});
// 按键排序
const sorted = {};
Object.keys(grouped).sort().forEach(key => {
sorted[key] = grouped[key];
});
return sorted;
}
// 更新详细统计
function updateDetailStats() {
if (appState.invoices.length === 0) return;
// 按客户统计
const customerMap = new Map();
appState.invoices.forEach(inv => {
const key = inv.客户名称;
if (!customerMap.has(key)) {
customerMap.set(key, { count: 0, amount: 0 });
}
const data = customerMap.get(key);
data.count++;
data.amount += inv.总金额;
});
const customerStatsHTML = generateDetailTable(
Array.from(customerMap.entries()).map(([name, data]) => ({
name,
count: data.count,
amount: data.amount
})),
'客户名称'
);
elements.customerStats.innerHTML = customerStatsHTML || '<div class="empty-stats">无数据</div>';
// 按仓库统计
const warehouseMap = new Map();
appState.invoices.forEach(inv => {
const key = inv.发货仓库;
if (!warehouseMap.has(key)) {
warehouseMap.set(key, { count: 0, amount: 0 });
}
const data = warehouseMap.get(key);
data.count++;
data.amount += inv.总金额;
});
const warehouseStatsHTML = generateDetailTable(
Array.from(warehouseMap.entries()).map(([name, data]) => ({
name,
count: data.count,
amount: data.amount
})),
'仓库名称'
);
elements.warehouseStats.innerHTML = warehouseStatsHTML || '<div class="empty-stats">无数据</div>';
// 按经手人统计
const handlerMap = new Map();
appState.invoices.forEach(inv => {
const key = inv.经手人;
if (!handlerMap.has(key)) {
handlerMap.set(key, { count: 0, amount: 0 });
}
const data = handlerMap.get(key);
data.count++;
data.amount += inv.总金额;
});
const handlerStatsHTML = generateDetailTable(
Array.from(handlerMap.entries()).map(([name, data]) => ({
name,
count: data.count,
amount: data.amount
})),
'经手人'
);
elements.handlerStats.innerHTML = handlerStatsHTML || '<div class="empty-stats">无数据</div>';
// 按商品统计
const productMap = new Map();
appState.invoices.forEach(inv => {
inv.商品明细.forEach(prod => {
const key = prod.商品名称;
if (!productMap.has(key)) {
productMap.set(key, { quantity: 0, amount: 0 });
}
const data = productMap.get(key);
data.quantity += prod.数量;
data.amount += prod.金额;
});
});
const productStatsHTML = generateProductTable(
Array.from(productMap.entries()).map(([name, data]) => ({
name,
quantity: data.quantity,
amount: data.amount
}))
);
elements.productStats.innerHTML = productStatsHTML || '<div class="empty-stats">无数据</div>';
}
// 生成详情表格
function generateDetailTable(data, nameHeader) {
if (!data || data.length === 0) return '';
const totalAmount = data.reduce((sum, item) => sum + item.amount, 0);
const maxAmount = Math.max(...data.map(item => item.amount));
let html = `
<table class="detail-table">
<thead>
<tr>
<th>${nameHeader}</th>
<th>单据数</th>
<th>金额</th>
<th>占比</th>
</tr>
</thead>
<tbody>
`;
data.forEach(item => {
const percentage = totalAmount > 0 ? (item.amount / totalAmount * 100).toFixed(1) : 0;
const isTop = item.amount === maxAmount;
html += `
<tr style="${isTop ? 'background: rgba(46, 204, 113, 0.1); font-weight: 600;' : ''}">
<td>${item.name} ${isTop ? '🏆' : ''}</td>
<td>${item.count}</td>
<td>¥${item.amount.toFixed(2)}</td>
<td>
${percentage}%
<div class="progress-bar">
<div class="progress-fill" style="width: ${percentage}%"></div>
</div>
</td>
</tr>
`;
});
html += `
</tbody>
<tfoot>
<tr style="font-weight: 700; background: var(--light);">
<td>合计</td>
<td>${data.reduce((sum, item) => sum + item.count, 0)}</td>
<td>¥${totalAmount.toFixed(2)}</td>
<td>100%</td>
</tr>
</tfoot>
</table>
`;
return html;
}
// 生成商品统计表格
function generateProductTable(data) {
if (!data || data.length === 0) return '';
const totalAmount = data.reduce((sum, item) => sum + item.amount, 0);
const totalQuantity = data.reduce((sum, item) => sum + item.quantity, 0);
const maxAmount = Math.max(...data.map(item => item.amount));
let html = `
<table class="detail-table">
<thead>
<tr>
<th>商品名称</th>
<th>销量</th>
<th>销售额</th>
<th>占比</th>
</tr>
</thead>
<tbody>
`;
data.forEach(item => {
const percentage = totalAmount > 0 ? (item.amount / totalAmount * 100).toFixed(1) : 0;
const isTop = item.amount === maxAmount;
html += `
<tr style="${isTop ? 'background: rgba(52, 152, 219, 0.1); font-weight: 600;' : ''}">
<td>${item.name} ${isTop ? '🥇' : ''}</td>
<td>${item.quantity}</td>
<td>¥${item.amount.toFixed(2)}</td>
<td>
${percentage}%
<div class="progress-bar">
<div class="progress-fill" style="width: ${percentage}%"></div>
</div>
</td>
</tr>
`;
});
html += `
</tbody>
<tfoot>
<tr style="font-weight: 700; background: var(--light);">
<td>合计</td>
<td>${totalQuantity}</td>
<td>¥${totalAmount.toFixed(2)}</td>
<td>100%</td>
</tr>
</tfoot>
</table>
`;
return html;
}
// 显示导入区域
function showImportArea() {
elements.importContainer.classList.remove('hidden');
elements.importContainer.scrollIntoView({ behavior: 'smooth' });
elements.importBtn.disabled = true;
}
// 隐藏导入区域
function hideImportArea() {
elements.importContainer.classList.add('hidden');
elements.importBtn.disabled = false;
clearJsonInput();
hidePreview();
hideValidation();
}
// 清空JSON输入
function clearJsonInput() {
elements.jsonTextarea.value = '';
elements.fileInput.value = '';
}
// 隐藏预览
function hidePreview() {
elements.previewSection.classList.remove('active');
appState.parsedData = null;
}
// 隐藏验证结果
function hideValidation() {
elements.validationResult.className = 'validation-result';
elements.validationResult.style.display = 'none';
}
// 拖拽处理
function handleDragOver(e) {
e.preventDefault();
elements.dropZone.classList.add('dragover');
}
function handleDragLeave(e) {
e.preventDefault();
elements.dropZone.classList.remove('dragover');
}
function handleDrop(e) {
e.preventDefault();
elements.dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
processFile(files[0]);
}
}
// 文件选择处理
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
processFile(file);
}
}
// 处理文件
function processFile(file) {
if (file.type !== 'application/json' && !file.name.endsWith('.json')) {
showToast('请选择JSON文件', 'error');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
try {
const json = JSON.parse(e.target.result);
elements.jsonTextarea.value = JSON.stringify(json, null, 2);
showToast('文件已加载,请确认导入', 'success');
} catch (error) {
showToast('JSON文件格式错误: ' + error.message, 'error');
}
};
reader.readAsText(file);
}
// 解析JSON数据
function parseJsonData() {
const jsonText = elements.jsonTextarea.value.trim();
if (!jsonText) {
showToast('请上传文件或粘贴JSON数据', 'error');
return;
}
try {
const data = JSON.parse(jsonText);
// 验证数据格式
const validation = validateData(data);
if (!validation.valid) {
showValidation(false, validation.message);
return;
}
// 保存解析的数据
appState.parsedData = validation.processedData;
// 显示预览
showPreview(validation.processedData, validation.stats);
showValidation(true, `✅ 数据格式正确!共 ${validation.stats.totalInvoices} 个单据,${validation.stats.totalProducts} 个商品条目`);
} catch (error) {
showValidation(false, `JSON解析错误: ${error.message}`);
}
}
// 验证数据格式
function validateData(data) {
const stats = {
totalInvoices: 0,
totalProducts: 0,
validInvoices: 0
};
// 如果是单个对象,转换为数组
const dataArray = Array.isArray(data) ? data : [data];
const processedData = [];
for (const invoice of dataArray) {
// 检查必需字段
const requiredFields = ['单据编号', '客户名称', '发货仓库', '经手人', '录单时间', '商品明细'];
for (const field of requiredFields) {
if (!invoice[field]) {
return { valid: false, message: `缺少必需字段: ${field}` };
}
}
// 检查商品明细
if (!Array.isArray(invoice.商品明细) || invoice.商品明细.length === 0) {
return { valid: false, message: `单据 ${invoice.单据编号} 的商品明细不能为空` };
}
// 验证并计算商品明细
const processedProducts = [];
let totalQuantity = 0;
let totalAmount = 0;
for (const product of invoice.商品明细) {
const requiredProductFields = ['行号', '商品名称', '数量', '单价', '金额'];
for (const field of requiredProductFields) {
if (product[field] === undefined || product[field] === null) {
return { valid: false, message: `商品缺少字段: ${field}` };
}
}
// 验证数值
if (typeof product.数量 !== 'number' || product.数量 <= 0) {
return { valid: false, message: `商品 ${product.商品名称} 的数量必须是正数` };
}
if (typeof product.单价 !== 'number' || product.单价 < 0) {
return { valid: false, message: `商品 ${product.商品名称} 的单价不能为负数` };
}
if (typeof product.金额 !== 'number' || product.金额 < 0) {
return { valid: false, message: `商品 ${product.商品名称} 的金额不能为负数` };
}
// 自动计算金额验证
const calculatedAmount = product.数量 * product.单价;
if (Math.abs(calculatedAmount - product.金额) > 0.01) {
// 允许微小差异,但给出警告
console.warn(`商品 ${product.商品名称} 金额不匹配: 计算值 ${calculatedAmount}, 提供值 ${product.金额}`);
}
processedProducts.push({
行号: product.行号,
商品名称: product.商品名称,
数量: product.数量,
单价: product.单价,
金额: product.金额,
备注: product.备注 || null
});
totalQuantity += product.数量;
totalAmount += product.金额;
}
// 构建处理后的单据
const processedInvoice = {
单据编号: invoice.单据编号,
客户名称: invoice.客户名称,
发货仓库: invoice.发货仓库,
经手人: invoice.经手人,
录单时间: invoice.录单时间,
商品明细: processedProducts,
合计数量: totalQuantity,
总金额: parseFloat(totalAmount.toFixed(2))
};
processedData.push(processedInvoice);
stats.totalInvoices++;
stats.totalProducts += processedProducts.length;
stats.validInvoices++;
}
return { valid: true, processedData, stats };
}
// 显示验证结果
function showValidation(isSuccess, message) {
elements.validationResult.textContent = message;
elements.validationResult.className = `validation-result ${isSuccess ? 'success' : 'error'}`;
elements.validationResult.style.display = 'block';
}
// 显示预览
function showPreview(data, stats) {
elements.previewContent.textContent = JSON.stringify(data, null, 2);
elements.previewStats.innerHTML = `
<div class="stat-item">单据数: <strong>${stats.totalInvoices}</strong></div>
<div class="stat-item">商品条目: <strong>${stats.totalProducts}</strong></div>
`;
elements.previewSection.classList.add('active');
elements.previewSection.scrollIntoView({ behavior: 'smooth' });
}
// 确认导入
function confirmImport() {
if (!appState.parsedData) {
showToast('没有可导入的数据', 'error');
return;
}
// 检查单据编号冲突
const existingNumbers = new Set(appState.invoices.map(inv => inv.单据编号));
const newInvoices = [];
const conflicts = [];
for (const invoice of appState.parsedData) {
if (existingNumbers.has(invoice.单据编号)) {
conflicts.push(invoice.单据编号);
} else {
newInvoices.push(invoice);
}
}
if (conflicts.length > 0) {
const proceed = confirm(
`以下单据编号已存在,将被跳过:\n${conflicts.join('\n')}\n\n是否继续导入其他单据?`
);
if (!proceed) {
return;
}
}
if (newInvoices.length === 0) {
showToast('所有导入的单据编号都已存在', 'error');
return;
}
// 添加新单据
appState.invoices.push(...newInvoices);
saveToLocalStorage();
renderInvoiceList();
updateInvoiceCount();
// 更新统计
if (elements.statsPanel.style.display !== 'none') {
updateStats();
}
showToast(`成功导入 ${newInvoices.length} 个单据`, 'success');
// 自动选中第一个新单据
if (newInvoices.length > 0) {
selectInvoice(newInvoices[0]);
}
hideImportArea();
}
// 渲染单据列表
function renderInvoiceList(data = null) {
const displayData = data || (appState.isSearching ? appState.searchResults : appState.invoices);
elements.invoiceList.innerHTML = '';
if (displayData.length === 0) {
const message = appState.isSearching ? '未找到匹配的单据' : '暂无单据,请导入数据';
elements.invoiceList.innerHTML = `
<div class="empty-state" style="padding: 20px; min-height: 200px;">
<p>${message}</p>
</div>
`;
return;
}
displayData.forEach((invoice, index) => {
const item = document.createElement('div');
item.className = 'invoice-item';
// 高亮搜索结果
if (appState.isSearching) {
item.classList.add('highlight');
}
if (appState.selectedInvoice && appState.selectedInvoice.单据编号 === invoice.单据编号) {
item.classList.add('active');
}
item.innerHTML = `
<div class="invoice-header">
<div class="invoice-number">${invoice.单据编号}</div>
<div class="invoice-date">${invoice.录单时间}</div>
</div>
<div class="invoice-details">
<span>👤 ${invoice.客户名称}</span>
<span>🏢 ${invoice.发货仓库}</span>
<span>👤 ${invoice.经手人}</span>
</div>
<div class="invoice-details">
<span>📦 ${invoice.合计数量} 件</span>
<span>💰 ¥${invoice.总金额}</span>
</div>
`;
item.addEventListener('click', () => selectInvoice(invoice));
elements.invoiceList.appendChild(item);
});
// 更新计数
if (appState.isSearching) {
elements.invoiceCount.textContent = `${displayData.length} / ${appState.invoices.length} 单`;
} else {
elements.invoiceCount.textContent = `${displayData.length} 单`;
}
}
// 选择单据
function selectInvoice(invoice) {
appState.selectedInvoice = invoice;
renderInvoiceList();
renderInvoiceDetail();
// 显示删除按钮
elements.deleteBtn.classList.remove('hidden');
}
// 渲染单据详情
function renderInvoiceDetail() {
if (!appState.selectedInvoice) {
elements.emptyState.style.display = 'flex';
elements.detailView.classList.remove('active');
return;
}
elements.emptyState.style.display = 'none';
elements.detailView.classList.add('active');
const inv = appState.selectedInvoice;
// 生成商品明细表格
let productsHTML = `
<table class="products-table">
<thead>
<tr>
<th>行号</th>
<th>商品名称</th>
<th>数量</th>
<th>单价</th>
<th>金额</th>
</tr>
</thead>
<tbody>
`;
inv.商品明细.forEach(product => {
productsHTML += `
<tr>
<td>${product.行号}</td>
<td>${product.商品名称}</td>
<td>${product.数量}</td>
<td>¥${product.单价}</td>
<td>¥${product.金额}</td>
</tr>
`;
});
productsHTML += `
<tr class="total-row">
<td colspan="3">合计</td>
<td>${inv.合计数量} 件</td>
<td>¥${inv.总金额}</td>
</tr>
</tbody>
</table>
`;
elements.detailView.innerHTML = `
<div class="detail-section">
<h3>📋 基本信息</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">单据编号</div>
<div class="detail-value">${inv.单据编号}</div>
</div>
<div class="detail-item">
<div class="detail-label">客户名称</div>
<div class="detail-value">${inv.客户名称}</div>
</div>
<div class="detail-item">
<div class="detail-label">发货仓库</div>
<div class="detail-value">${inv.发货仓库}</div>
</div>
<div class="detail-item">
<div class="detail-label">经手人</div>
<div class="detail-value">${inv.经手人}</div>
</div>
<div class="detail-item">
<div class="detail-label">录单时间</div>
<div class="detail-value">${inv.录单时间}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>📦 商品明细</h3>
${productsHTML}
</div>
`;
}
// 删除单据
function deleteInvoice() {
if (!appState.selectedInvoice) return;
if (confirm(`确定要删除单据 ${appState.selectedInvoice.单据编号} 吗?`)) {
appState.invoices = appState.invoices.filter(
inv => inv.单据编号 !== appState.selectedInvoice.单据编号
);
appState.selectedInvoice = null;
saveToLocalStorage();
renderInvoiceList();
renderInvoiceDetail();
updateInvoiceCount();
elements.deleteBtn.classList.add('hidden');
// 更新统计
if (elements.statsPanel.style.display !== 'none') {
updateStats();
// 如果有时间筛选,也更新时间统计
if (appState.filteredTimeData.length > 0) {
filterByTime();
}
}
// 如果正在搜索,更新搜索结果
if (appState.isSearching) {
performSearch();
}
showToast('单据已删除', 'success');
}
}
// 导出数据
function exportData() {
if (appState.invoices.length === 0) {
showToast('没有数据可导出', 'error');
return;
}
const dataStr = JSON.stringify(appState.invoices, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `销售单据_${new Date().toISOString().slice(0,10)}.json`;
link.click();
showToast('数据导出成功', 'success');
}
// 重置数据
function resetData() {
if (confirm('确定要清空所有数据吗?此操作不可恢复。')) {
appState.invoices = [];
appState.selectedInvoice = null;
appState.filteredTimeData = [];
appState.searchResults = [];
appState.isSearching = false;
saveToLocalStorage();
renderInvoiceList();
renderInvoiceDetail();
updateInvoiceCount();
elements.deleteBtn.classList.add('hidden');
// 清空统计
if (elements.statsPanel.style.display !== 'none') {
updateStats();
elements.timeStatsResult.innerHTML = '<div class="empty-stats">请选择日期范围查看统计</div>';
elements.timeChart.innerHTML = '<div class="chart-empty">暂无图表数据</div>';
}
// 清空搜索
if (elements.searchContainer.classList.contains('active')) {
clearSearch();
}
showToast('数据已清空', 'success');
}
}
// 保存到localStorage
function saveToLocalStorage() {
localStorage.setItem('invoiceData', JSON.stringify(appState.invoices));
}
// 更新单据计数
function updateInvoiceCount() {
elements.invoiceCount.textContent = `${appState.invoices.length} 单`;
}
// 显示提示
function showToast(message, type = 'success') {
elements.toast.textContent = message;
elements.toast.className = `toast ${type}`;
elements.toast.classList.add('show');
setTimeout(() => {
elements.toast.classList.remove('show');
}, 3000);
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initApp);
</script>
</body>
</html>
附加:
用Nginx 作服务器 ,后端FastAPI , 外网 用 节点小宝