初始提交:韬智生产管理系统 功能特性: - 用户认证系统(登录/登出/权限管理) - 仪表盘数据展示 - 数据上传功能(MAC批次、良/不良统计、返修记录、发货记录) - 批量导入功能(支持Excel/CSV文件) - 出货查询功能(支持SN查询) - 扩展采集(设备状态、环境参数、人员信息、质检报告、生产时间) - 数据导出功能(Excel/PDF) - 通知系统(超级管理员) - 用户头像上传功能 - 深色/浅色主题切换 - 响应式设计 技术栈: - 后端:Python Flask + SQLite - 前端:原生 JavaScript + CSS - 缓存:Redis - 部署:Nginx + Systemd

This commit is contained in:
zzh 2025-11-21 21:27:40 +08:00
commit c9efcd3571
50 changed files with 7464 additions and 0 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Redis 配置
# 如果 Redis 在本地运行,使用 127.0.0.1 可以显著提升性能
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=Zzh08165511
# 管理员密码
SUPERADMIN_PASSWORD=Zzh08165511
# 服务器配置
HOST=0.0.0.0
PORT=5000

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
server.log
# Environment variables
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Uploaded files
frontend/assets/avatars/*
!frontend/assets/avatars/.gitkeep
# Redis dump
dump.rdb
# Temporary files
*.tmp
*.bak
*.cache

116
FPY_FEATURE.md Normal file
View File

@ -0,0 +1,116 @@
# 直通良品率FPY功能说明
## 功能概述
新增了直通良品率First Pass Yield, FPY统计功能用于追踪首次通过测试的产品数量。
## 什么是直通良品率?
**直通良品率FPY** = 首次通过测试的产品数量 / 总生产数量 × 100%
- **直通良品**:首次测试就通过的产品
- **良品**:包括首次通过和返修后通过的所有合格产品
- **不良品**:测试未通过的产品
### 示例
假设生产了100个产品
- 80个首次测试通过直通良品
- 15个首次测试失败返修后通过
- 5个无法修复
则:
- **直通良品率** = 80/100 = 80%
- **总良品率** = (80+15)/100 = 95%
- **不良率** = 5/100 = 5%
## 功能实现
### 1. 数据库变更
`stats` 表中新增 `fpy_good` 字段:
```sql
ALTER TABLE stats ADD COLUMN fpy_good INTEGER DEFAULT 0
```
### 2. 仪表盘显示
在仪表盘顶部新增"直通良品率"卡片,显示在"良品率"之前:
- 直通良品率
- 良品率
- 发货数量
- 不良数量
### 3. 上传页面
在"良/不良统计"上传页面新增"直通良品数量"输入框:
- 直通良品数量(新增)
- 良品数量
- 不良数量
- 不良明细(可选)
### 4. API变更
#### 后端API
- `GET /api/dashboard` - 返回 `fpyRate` 字段
- `POST /api/upload/stats` - 接受 `fpy_good` 参数
- `GET /api/overview` - 返回 `fpyGoodTotal` 字段
- `GET /api/list/stats` - 返回 `fpy_good` 字段
#### 前端API
- `API.uploadStats({fpy_good, good, bad, details})` - 上传时包含直通良品数
## 使用方法
### 1. 上传统计数据
1. 进入"数据上传" -> "良/不良统计"
2. 填写:
- 直通良品数量:首次测试通过的数量
- 良品数量:所有合格产品数量(包括返修后合格的)
- 不良数量:测试失败的数量
3. 点击"上传"
### 2. 查看统计
在仪表盘可以看到:
- **直通良品率**:反映生产质量的稳定性
- **良品率**:反映最终产品的合格率
## 计算公式
```javascript
// 直通良品率
fpyRate = (fpy_good / (good + bad)) × 100%
// 总良品率
goodRate = (good / (good + bad)) × 100%
```
## 数据关系
```
总生产数 = 良品数 + 不良数
良品数 = 直通良品数 + 返修后合格数
直通良品数 ≤ 良品数 ≤ 总生产数
```
## 注意事项
1. **直通良品数不能大于良品数**:系统不会强制校验,请确保数据准确
2. **历史数据兼容**:旧数据的 `fpy_good` 默认为 0
3. **显示逻辑**:如果没有数据,显示为"—"
4. **通知功能**:上传时会通知超级管理员,包含直通良品数
## 业务价值
1. **质量监控**:直通良品率越高,说明生产过程越稳定
2. **成本控制**:减少返修可以降低成本
3. **流程优化**:通过对比直通良品率和总良品率,识别返修环节的效率
4. **趋势分析**:长期追踪直通良品率变化,发现质量问题
## 示例数据
| 日期 | 直通良品 | 良品 | 不良 | 直通良品率 | 总良品率 |
|------|---------|------|------|-----------|---------|
| 2025-01-01 | 80 | 95 | 5 | 80% | 95% |
| 2025-01-02 | 85 | 92 | 8 | 85% | 92% |
| 2025-01-03 | 90 | 96 | 4 | 90% | 96% |
从上表可以看出:
- 直通良品率在提升80% → 85% → 90%),说明生产质量在改善
- 总良品率保持在高位95%左右),说明返修环节有效

160
MEMORY_OPTIMIZATION.md Normal file
View File

@ -0,0 +1,160 @@
# 内存优化说明
## 优化内容
### 1. Dashboard 组件资源清理
**清理策略(三重保护):**
- **策略1**: 每5次更新清理一次约50秒
- **策略2**: 数据量超过500条时自动清理
- **策略3**: 超过1分钟强制清理
**清理内容:**
- 拼多多和圆通审计数据缓存
- Canvas 画布内容
- 趋势图数据对象
- 触发浏览器垃圾回收(如果支持)
**数据过滤:**
- 只缓存最近30天的数据
- 自动过滤超过30天的旧数据
- 减少内存占用
**日志输出:**
```javascript
console.log('[资源清理] 清理缓存数据,更新次数:', count, '数据量:', pddLength, ytLength);
```
### 2. Upload 组件事件监听器管理
**问题:**
- 每次渲染页面都添加新的事件监听器
- 旧的监听器没有被移除
- 导致内存泄漏
**解决方案:**
- 创建统一的事件监听器管理系统
- 使用 `addListener()` 替代 `addEventListener()`
- 页面切换时自动清理所有监听器
**实现:**
```javascript
const eventListeners = [];
const addListener = (element, event, handler) => {
if(element){
element.addEventListener(event, handler);
eventListeners.push({element, event, handler});
}
};
const cleanupListeners = () => {
eventListeners.forEach(({element, event, handler}) => {
element.removeEventListener(event, handler);
});
eventListeners.length = 0;
};
```
### 3. 内存监控工具
**功能:**
- 实时监控 JavaScript 堆内存使用情况
- 每10秒输出一次内存状态
- 内存使用超过70%时发出警告
**使用方法:**
```javascript
// 开发环境自动启动
// 生产环境手动启动
MemoryMonitor.start(10000); // 每10秒监控
// 查看当前内存状态
MemoryMonitor.logCurrent();
// 停止监控
MemoryMonitor.stop();
```
**输出示例:**
```
[内存监控] 使用: 45.23 MB / 2048.00 MB (2.21%)
[内存警告] 内存使用率超过70%,建议清理资源
```
## 监控方法
### 浏览器开发者工具
1. **打开控制台**F12 → Console
2. **查看内存日志**
- `[资源清理]` - 缓存清理日志
- `[内存监控]` - 内存使用情况
- `[内存警告]` - 内存使用过高警告
3. **性能监控**F12 → Performance
- 点击 Record 开始录制
- 使用应用一段时间
- 停止录制查看内存曲线
4. **内存快照**F12 → Memory
- 选择 Heap snapshot
- 拍摄快照对比内存变化
### 手动测试
```javascript
// 在控制台执行
MemoryMonitor.logCurrent();
// 查看缓存状态
console.log('Dashboard缓存:', window.__auditCache);
// 查看定时器
console.log('定时器ID:', window.__auditTimer);
```
## 预期效果
### 优化前
- 内存持续增长
- 30秒清理无明显效果
- 长时间使用后内存占用过高
### 优化后
- 内存使用稳定在合理范围
- 每50秒或数据量达到500条时自动清理
- 超过1分钟强制清理
- 只保留最近30天数据
- 事件监听器正确清理
## 注意事项
1. **浏览器支持**
- `performance.memory` API 仅在 Chrome/Edge 中可用
- Firefox/Safari 不支持此 API
2. **垃圾回收**
- `window.gc()` 需要浏览器启动时添加 `--js-flags="--expose-gc"` 参数
- 正常情况下浏览器会自动进行垃圾回收
3. **开发环境**
- 内存监控工具在 localhost 自动启动
- 生产环境需要手动启动
## 调整参数
如需调整清理策略,修改 `frontend/js/components/dashboard.js`
```javascript
// 修改清理条件
const shouldClean = cache.updateCount >= 5 || // 改为10次
(cache.pdd && cache.pdd.length > 500) || // 改为1000条
(now - cache.lastClean > 60000); // 改为1200002分钟
```
## 测试建议
1. 打开浏览器控制台
2. 进入 Dashboard 页面
3. 观察内存监控日志
4. 等待约1分钟查看清理日志
5. 切换到其他页面,确认定时器已停止

130
NOTIFICATION_DEBUG.md Normal file
View File

@ -0,0 +1,130 @@
# 通知系统调试指南
## 已修复的问题
### 1. 铃铛无法点击问题
**原因:**
- 仪表盘页面有复杂的Canvas元素和图表可能遮挡铃铛按钮
- z-index层级设置不够高
- 事件可能被其他元素捕获
**解决方案:**
- 提高铃铛按钮的z-index到999
- 提高通知面板的z-index到1000
- 给content-header添加z-index:10
- 给#actions容器添加z-index:999
- 徽章设置pointer-events:none避免阻挡点击
- 铃铛按钮设置pointer-events:auto确保可点击
### 2. 点击铃铛没有弹出面板
**原因:**
- 通知系统可能被多次初始化,导致事件监听器混乱
- 事件监听器可能被重复绑定
**解决方案:**
- 添加isInitialized标志防止重复初始化
- 使用cloneNode()方法移除旧的事件监听器
- 添加详细的console.log调试信息
- 在togglePanel中添加元素存在性检查
### 3. 时间显示问题
**原因:**
- 后端使用UTC时间前端显示时差8小时
**解决方案:**
- 后端notify_superadmin()使用北京时间UTC+8
- 前端正确解析ISO格式的时间字符串
## 调试方法
### 1. 检查铃铛是否显示
打开浏览器控制台,查看是否有以下日志:
```
[Notifications] 铃铛已显示
[Notifications] 初始化完成
```
### 2. 检查点击事件
点击铃铛时,应该看到:
```
[Notifications] 铃铛被点击
[Notifications] 面板状态: 打开
```
### 3. 检查元素层级
在浏览器开发者工具中:
1. 检查#notification-bell的z-index是否为999
2. 检查#notification-panel的z-index是否为1000
3. 检查.content-header的z-index是否为10
4. 检查#actions的z-index是否为999
### 4. 检查CSS样式
确认以下样式已应用:
```css
.notification-bell {
z-index: 999;
pointer-events: auto;
}
.notification-badge {
z-index: 1000;
pointer-events: none;
}
.notification-panel {
z-index: 1000;
}
.content-header {
z-index: 10;
}
#actions {
z-index: 999;
}
```
### 5. 手动测试
在浏览器控制台执行:
```javascript
// 检查铃铛元素
const bell = document.getElementById('notification-bell');
console.log('铃铛元素:', bell);
console.log('铃铛样式:', window.getComputedStyle(bell));
// 检查面板元素
const panel = document.getElementById('notification-panel');
console.log('面板元素:', panel);
console.log('面板显示:', panel.style.display);
// 手动触发点击
bell.click();
```
## 常见问题
### Q: 铃铛显示了但点击没反应
A: 检查浏览器控制台是否有错误信息,确认事件监听器已绑定
### Q: 面板打开了但看不到
A: 检查z-index是否被其他元素覆盖或者面板位置是否在屏幕外
### Q: 时间显示还是不对
A: 确认服务器时区设置,检查后端是否使用了北京时间
### Q: 在某些页面可以点击,某些页面不行
A: 检查该页面是否有特殊的z-index或overflow设置
## 性能优化
1. **防止重复初始化**使用isInitialized标志
2. **移除旧事件监听器**使用cloneNode()方法
3. **定时器管理**在cleanup时清理定时器
4. **避免内存泄漏**在页面切换时调用cleanup
## 代码改进点
1. 添加了详细的日志输出,方便调试
2. 添加了元素存在性检查,避免空指针错误
3. 使用e.preventDefault()和e.stopPropagation()防止事件冒泡
4. 提高了z-index层级确保在所有页面都能正常显示

94
NOTIFICATION_SYSTEM.md Normal file
View File

@ -0,0 +1,94 @@
# 消息通知系统使用说明
## 功能概述
为超级管理员账号添加了消息通知系统,可以实时查看其他用户的操作记录。
## 主要特性
### 1. 消息铃铛
- 位置:页面右上角(仅超级管理员可见)
- 红色圆点徽章:显示未读消息数量
- 点击铃铛:打开/关闭消息面板
### 2. 消息面板
- 显示最近100条操作通知
- 未读消息有蓝色圆点标记和高亮背景
- 点击单条消息:标记为已读
- "全部已读"按钮:一键标记所有消息为已读
- "删除已读"按钮:删除所有已读消息(需确认)
### 3. 自动更新
- 每30秒自动刷新未读消息数量
- 实时显示最新的用户操作
### 4. 时间显示
- 使用北京时间UTC+8
- 智能显示刚刚、X分钟前、X小时前、X天前
- 超过7天显示完整日期时间
## 会触发通知的操作
以下操作会为超级管理员创建通知:
1. **批量上传MAC文件** - 显示上传类型pdd/yt/tx
2. **批量上传发货记录文件** - 显示箱数和总数量
3. **添加人员信息** - 显示姓名和角色
4. **上传发货记录** - 显示日期和数量
5. **上传MAC与批次** - 显示记录数量
6. **批量上传不良明细文件** - 显示记录数量
7. **上传返修记录** - 显示数量
8. **上传良/不良统计** - 显示良品和不良品数量
9. **上传不良明细** - 显示记录数量
## 通知内容
每条通知包含:
- **用户名**:执行操作的用户
- **操作类型**:执行的具体操作
- **详细信息**:操作的详细数据(如数量、日期等)
- **时间**操作时间智能显示刚刚、X分钟前、X小时前等
## 技术实现
### 后端
- 新增 `notifications` 数据表
- 新增 `notify_superadmin()` 函数,在关键操作点调用,使用北京时间
- 新增 5 个 API 端点:
- `GET /api/notifications` - 获取通知列表
- `GET /api/notifications/unread-count` - 获取未读数量
- `POST /api/notifications/mark-read` - 标记单条已读
- `POST /api/notifications/mark-all-read` - 标记全部已读
- `POST /api/notifications/delete-read` - 删除所有已读消息
### 前端
- 新增 `notifications.js` 组件
- 在 `index.html` 添加铃铛和通知面板
- 在 `styles.css` 添加通知相关样式
- 在 `app.js` 集成通知系统初始化
## 使用方法
1. 使用超级管理员账号登录系统
2. 在页面右上角可以看到消息铃铛图标 🔔
3. 当有新消息时,铃铛上会显示红色数字徽章
4. 点击铃铛查看消息列表
5. 点击单条消息标记为已读
6. 点击"全部已读"按钮标记所有消息为已读
7. 点击"删除已读"按钮清理已读消息(需确认)
## 注意事项
- 只有超级管理员role='superadmin')才能看到消息铃铛
- 超级管理员自己的操作不会创建通知
- 通知数据存储在 SQLite 数据库中
- 消息列表最多显示最近100条记录
- 所有时间使用北京时间UTC+8
- 删除已读消息操作不可恢复,请谨慎操作
## 修复的问题
### v1.1 更新
1. **修复时间显示问题**后端使用北京时间UTC+8存储前端正确解析显示
2. **修复铃铛无法点击问题**调整z-index层级确保铃铛和面板在最上层
3. **新增删除已读功能**:可以清理已读消息,保持消息列表整洁

133
batch_import.py Normal file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
import pandas as pd
import redis
from tqdm import tqdm
import argparse
import os
# 连接Redis
parser = argparse.ArgumentParser()
parser.add_argument("type", choices=["pdd", "yt", "tx"], help="目标: pdd/yt/tx")
args = parser.parse_args()
r = redis.Redis(host='180.163.74.83', port=6379, password='Zzh08165511', decode_responses=True)
# 读取Excel文件
if args.type == "yt":
excel_path = '/home/hyx/work/batch_import_xlsx/sn_test_yt.xlsx'
pool = 'batch_sn_mapping_yt'
mac_col = 'MAC'
elif args.type == "pdd":
excel_path = '/home/hyx/work/batch_import_xlsx/sn_test_pdd.xlsx'
pool = 'batch_sn_mapping_pdd'
mac_col = 'MAC'
else:
excel_path = '/home/hyx/work/batch_import_xlsx/sn_test_tx.xlsx'
pool = 'batch_sn_mapping'
mac_col = 'SN_MAC'
df = pd.read_excel(excel_path)
existing = r.hgetall(pool)
mac_to_batches = {}
for b, m in existing.items():
mac_to_batches.setdefault(m, []).append(b)
s = df[mac_col].astype(str).str.strip()
dup_keys = set(s[s.duplicated(keep=False)].unique())
# 批量导入数据
pipe = r.pipeline()
duplicates = []
inserted_count = 0
invalids = []
duplicates_current = {}
dup_current_count = 0
for index, row in tqdm(df.iterrows(), total=len(df)):
batch_no = str(row['批次号']).strip()
sn_mac = str(row[mac_col]).strip()
expected_len = 27 if args.type == 'tx' else 12
if len(sn_mac) != expected_len:
invalids.append((sn_mac, batch_no))
continue
if sn_mac in dup_keys:
s = duplicates_current.get(sn_mac, set())
s.add(batch_no)
duplicates_current[sn_mac] = s
dup_current_count += 1
continue
if sn_mac in mac_to_batches:
for b in mac_to_batches[sn_mac]:
duplicates.append((sn_mac, b))
continue
pipe.hset(pool, batch_no, sn_mac)
inserted_count += 1
if (index + 1) % 100 == 0:
pipe.execute()
pipe = r.pipeline()
pipe.execute()
print(f"成功导入 {inserted_count} 条数据,数据库重复跳过 {len(duplicates)} 条,当前批次重复跳过 {dup_current_count} 条,长度错误跳过 {len(invalids)}")
# 输出成功导入的数据JSON格式方便前端解析
if inserted_count > 0:
print("\n=== 成功导入的数据 ===")
import json
success_records = []
for index, row in df.iterrows():
batch_no = str(row['批次号']).strip()
sn_mac = str(row[mac_col]).strip()
expected_len = 27 if args.type == 'tx' else 12
# 只输出成功导入的记录
if len(sn_mac) == expected_len and sn_mac not in dup_keys and sn_mac not in mac_to_batches:
success_records.append({
'mac': sn_mac,
'batch': batch_no
})
if len(success_records) >= 10: # 只显示前10条
break
print(json.dumps(success_records, ensure_ascii=False))
print("=== 数据输出结束 ===")
if duplicates:
for mac, b in duplicates:
print(f"重复: {mac} 已存在于批次号 {b}")
dup_df = pd.DataFrame(duplicates, columns=[mac_col, '批次号'])
out_path = f"/home/hyx/work/batch_import_xlsx/duplicates_{args.type}.xlsx"
if os.path.exists(out_path):
old_df = pd.read_excel(out_path)
combined = pd.concat([old_df, dup_df], ignore_index=True)
combined.to_excel(out_path, index=False)
else:
dup_df.to_excel(out_path, index=False)
#print(f"重复数据已导出: {out_path}")
if duplicates_current:
for mac, bs in duplicates_current.items():
for b in bs:
print(f"重复: {mac} 当前批次号 {b}")
cur_rows = [(mac, b) for mac, bs in duplicates_current.items() for b in bs]
cur_dup_df = pd.DataFrame(cur_rows, columns=[mac_col, '批次号'])
out_path_cur = f"/home/hyx/work/batch_import_xlsx/duplicates_current_{args.type}.xlsx"
if os.path.exists(out_path_cur):
old_cur_df = pd.read_excel(out_path_cur)
combined_cur = pd.concat([old_cur_df, cur_dup_df], ignore_index=True)
combined_cur.to_excel(out_path_cur, index=False)
else:
cur_dup_df.to_excel(out_path_cur, index=False)
#print(f"当前批次重复数据已导出: {out_path_cur}")
if invalids:
for mac, b in invalids:
print(f"长度错误: {mac} 批次号 {b}")
inv_df = pd.DataFrame(invalids, columns=[mac_col, '批次号'])
out_path_inv = f"/home/hyx/work/batch_import_xlsx/invalid_{args.type}.xlsx"
if os.path.exists(out_path_inv):
old_inv_df = pd.read_excel(out_path_inv)
combined_inv = pd.concat([old_inv_df, inv_df], ignore_index=True)
combined_inv.to_excel(out_path_inv, index=False)
else:
inv_df.to_excel(out_path_inv, index=False)
#print(f"长度错误数据已导出: {out_path_inv}")

46
check_excel.py Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
import pandas as pd
import openpyxl
import warnings
# 过滤openpyxl的跨平台兼容性警告
warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl')
file_path = '/home/hyx/work/batch_import_xlsx/sn_test_tx.xlsx'
print("检查Excel文件信息...")
try:
# 使用openpyxl检查工作表兼容Windows到Mac的Excel文件
wb = openpyxl.load_workbook(file_path, data_only=True)
print(f"工作表数量: {len(wb.sheetnames)}")
print(f"工作表名称: {wb.sheetnames}")
if wb.sheetnames:
ws = wb.active
print(f"活动工作表: {ws.title}")
print(f"最大行数: {ws.max_row}")
print(f"最大列数: {ws.max_column}")
# 显示前几行数据
print("\n前10行数据:")
for i, row in enumerate(ws.iter_rows(values_only=True), 1):
if i <= 10:
print(f"{i}行: {row}")
else:
break
wb.close() # 关闭工作簿释放资源
except Exception as e:
print(f"openpyxl错误: {e}")
print("提示: 这可能是Windows到Mac的Excel文件兼容性问题")
try:
# 使用pandas检查
print("\n使用pandas检查...")
xl_file = pd.ExcelFile(file_path)
print(f"pandas检测到的工作表: {xl_file.sheet_names}")
except Exception as e:
print(f"pandas错误: {e}")

View File

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
创建发货记录 Excel 模板文件
"""
import pandas as pd
from datetime import datetime, timedelta
def create_template():
"""创建发货记录模板文件(带合并单元格)"""
import openpyxl
from openpyxl.styles import Alignment, Font, Border, Side
# 创建工作簿
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "发货记录"
# 创建表头
headers = ['出货日期', '箱号']
headers.extend([f'SN{i}' for i in range(1, 21)])
ws.append(headers)
# 设置表头样式
for cell in ws[1]:
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal='center', vertical='center')
# 创建示例数据
base_date = datetime.now()
row_num = 2
# 每个日期3个箱子
for day in range(3):
date = (base_date + timedelta(days=day)).strftime('%Y-%m-%d')
start_row = row_num
# 每天3个箱子
for box in range(3):
box_num = f"BOX{day*3+box+1:03d}"
# 第一列:日期(只在第一行写入,后面会合并)
if box == 0:
ws.cell(row=row_num, column=1, value=date)
# 第二列:箱号
ws.cell(row=row_num, column=2, value=box_num)
# SN1-SN20
for sn_idx in range(1, 21):
sn_value = f"SN{(day*3+box)*20+sn_idx:04d}" if sn_idx <= 15 else ''
ws.cell(row=row_num, column=2+sn_idx, value=sn_value)
row_num += 1
# 合并日期单元格
if start_row < row_num - 1:
ws.merge_cells(f'A{start_row}:A{row_num-1}')
# 设置合并单元格的对齐方式
ws.cell(row=start_row, column=1).alignment = Alignment(horizontal='center', vertical='center')
# 调整列宽
ws.column_dimensions['A'].width = 12
ws.column_dimensions['B'].width = 10
for i in range(3, 23):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = 10
# 保存文件
output_file = 'shipments_template.xlsx'
wb.save(output_file)
print(f"✓ 模板文件已创建:{output_file}")
print(f" - 包含 {row_num-2} 行示例数据")
print(f" - 列出货日期合并单元格、箱号、SN1-SN20")
print(f" - 每个日期包含 3 个箱子")
return output_file
def create_empty_template():
"""创建空白模板文件"""
# 创建列头
columns = ['出货日期', '箱号']
columns.extend([f'SN{i}' for i in range(1, 21)])
# 创建空 DataFrame
df = pd.DataFrame(columns=columns)
# 保存为 Excel
output_file = 'shipments_template_empty.xlsx'
df.to_excel(output_file, index=False, engine='openpyxl')
print(f"✓ 空白模板文件已创建:{output_file}")
return output_file
if __name__ == '__main__':
print("创建发货记录 Excel 模板...\n")
# 创建带示例数据的模板
create_template()
print()
# 创建空白模板
create_empty_template()
print()
print("完成!您可以使用这些模板文件进行测试。")

1
dashboard.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763642480325" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="22385" data-spm-anchor-id="a313x.search_index.0.i8.510d3a81Nn5QNN" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M485.07804233 555.34391534l364.24370884 250.41244254a464.06874467 464.06874467 0 0 1-375.48791719 189.82773266C218.28958704 995.58409054 11.12158537 791.84818736 11.12158537 540.53686189 11.12158537 289.20919308 218.28958704 85.47328989 473.83383398 85.47328989c3.80799497 0 7.46890001 0.45761314 11.24420835 0.5556731V555.34391534z" fill="#eb7627" p-id="22386" data-spm-anchor-id="a313x.search_index.0.i4.510d3a81Nn5QNN" class=""></path><path d="M1004.12573602 489.97061092H566.79467283V19.26647586c244.08757534 12.61704775 438.19725945 210.66547345 438.19725948 453.85416585 0 5.68747749-0.65373305 11.21152169-0.86619629 16.84996921z" fill="#f4ea2a" p-id="22387" data-spm-anchor-id="a313x.search_index.0.i5.510d3a81Nn5QNN" class=""></path><path d="M1021.3189151 539.00058924a446.43429582 446.43429582 0 0 1-51.67759716 207.91979467L575.97962211 543.74015381V539.00058924h445.33929299z" fill="#d81e06" p-id="22388" data-spm-anchor-id="a313x.search_index.0.i7.510d3a81Nn5QNN" class=""></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

1
date-dark.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763626721741" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10947" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M935.812877 88.243078h-87.685776V29.784188a29.226886 29.226886 0 1 0-58.453771 0v58.45889H234.331789V29.784188a29.226886 29.226886 0 1 0-58.453771 0v58.45889H88.187123C39.762336 88.243078 0.501347 127.498948 0.501347 175.923735v759.945098c0 48.429907 39.260989 87.685776 87.685776 87.685776h847.625754c48.424787 0 87.685776-39.260989 87.685776-87.685776V175.928854c0-48.424787-39.260989-87.685776-87.685776-87.685776z m29.226886 847.625755a29.232005 29.232005 0 0 1-29.226886 29.232005H88.187123a29.232005 29.232005 0 0 1-29.226886-29.232005V351.300406h906.079526v584.568427z m0-643.027317H58.960237V175.928854a29.226886 29.226886 0 0 1 29.226886-29.226885h87.685776v29.226885a29.226886 29.226886 0 1 0 58.453771 0v-29.226885h555.341541v29.226885a29.226886 29.226886 0 1 0 58.453771 0v-29.226885h87.685776a29.226886 29.226886 0 0 1 29.226885 29.226885V292.841516z" p-id="10948" fill="#ffffff"></path><path d="M512 526.671958c24.179117 0 43.842888-19.663771 43.842888-43.842888A43.842888 43.842888 0 0 0 512 438.986182a43.807052 43.807052 0 0 0-43.842888 43.842888c0 24.173998 19.597218 43.842888 43.842888 43.842888zM745.825323 877.409942c24.184237 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.801932 43.801932 0 0 0-43.842888 43.842888c0.005119 24.179117 19.602337 43.842888 43.842888 43.842888zM512 702.03839c24.179117 0 43.842888-19.658651 43.842888-43.842888 0-24.240551-19.663771-43.837769-43.842888-43.837768a43.801932 43.801932 0 0 0-43.842888 43.837768A43.842888 43.842888 0 0 0 512 702.03839zM745.825323 526.671958c24.184237 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.807052 43.807052 0 0 0-43.842888 43.842888c0.005119 24.173998 19.602337 43.842888 43.842888 43.842888zM745.825323 702.03839c24.184237 0 43.842888-19.658651 43.842888-43.842888 0-24.240551-19.663771-43.837769-43.842888-43.837768a43.801932 43.801932 0 0 0-43.842888 43.837768c0.005119 24.184237 19.602337 43.842888 43.842888 43.842888zM512 877.409942c24.179117 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.801932 43.801932 0 0 0-43.842888 43.842888 43.842888 43.842888 0 0 0 43.842888 43.842888zM278.174677 877.409942c24.179117 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.801932 43.801932 0 0 0-43.842888 43.842888 43.842888 43.842888 0 0 0 43.842888 43.842888zM278.174677 702.03839c24.179117 0 43.842888-19.658651 43.842888-43.842888 0-24.240551-19.663771-43.837769-43.842888-43.837768a43.801932 43.801932 0 0 0-43.842888 43.837768 43.842888 43.842888 0 0 0 43.842888 43.842888zM278.174677 526.671958c24.179117 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.807052 43.807052 0 0 0-43.842888 43.842888c0 24.173998 19.597218 43.842888 43.842888 43.842888z" p-id="10949" fill="#ffffff"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

1
date.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763626025824" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5099" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M819.057629 645.954349l-62.937128 0 0 62.999554 62.937128 0L819.057629 645.954349zM819.057629 771.829612l-62.937128 0 0 62.999554 62.937128 0L819.057629 771.829612zM630.244232 520.078079l-62.938135 0 0 63.00056 62.938135 0L630.244232 520.078079zM630.244232 79.389305 378.494713 79.389305l0 62.938135 251.749519 0L630.244232 79.389305zM284.333689 204.435918c17.270758 0 31.16147-18.747829 31.16147-41.857399L315.495159 58.24715c0-23.048151-13.890712-41.794973-31.16147-41.794973-17.146914 0-31.099044 18.746823-31.099044 41.794973l0 104.332376C253.234645 185.689095 267.186775 204.435918 284.333689 204.435918M724.90164 204.435918c17.576845 0 31.832042-18.747829 31.832042-41.857399L756.733682 58.24715c0-23.048151-14.255197-41.794973-31.832042-41.794973-17.58188 0-31.838083 18.746823-31.838083 41.794973l0 104.332376C693.063556 185.689095 707.31976 204.435918 724.90164 204.435918M630.244232 645.954349l-62.938135 0 0 62.999554 62.938135 0L630.244232 645.954349zM819.057629 520.078079l-62.937128 0 0 63.00056 62.937128 0L819.057629 520.078079zM252.618443 771.829612l-62.939142 0 0 63.058959 62.939142 0L252.618443 771.829612zM252.618443 645.954349l-62.939142 0 0 62.999554 62.939142 0L252.618443 645.954349zM944.932892 79.389305 819.057629 79.389305l0 62.938135 125.87627 0 0 188.815411L63.805046 331.142851 63.805046 142.32744l125.875263 0L189.680309 79.389305 63.805046 79.389305c-65.271041 0-62.938135 64.567243-62.938135 62.938135l0 818.192732c0 61.277814 60.604221 62.938135 62.938135 62.938135l881.128853 0c57.406418 0 62.937128-60.601201 62.937128-62.938135L1007.871027 142.32744C1007.87002 138.057324 1006.21272 79.389305 944.932892 79.389305M944.932892 960.520172 63.805046 960.520172 63.805046 394.080986l881.128853 0L944.933899 960.520172zM630.244232 771.829612l-62.938135 0 0 63.058959 62.938135 0L630.244232 771.829612zM441.433855 645.954349l-62.939142 0 0 62.999554 62.939142 0L441.433855 645.954349zM441.433855 520.078079l-62.939142 0 0 63.00056 62.939142 0L441.433855 520.078079zM441.433855 771.829612l-62.939142 0 0 63.058959 62.939142 0L441.433855 771.829612zM252.618443 520.078079l-62.939142 0 0 63.00056 62.939142 0L252.618443 520.078079z" fill="#272636" p-id="5100"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,12 @@
server {
listen 80 default_server;
server_name 180.163.74.83;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -0,0 +1,12 @@
[Unit]
Description=Production Management Flask App
After=network.target
[Service]
WorkingDirectory=/home/hyx/work/生产管理系统
ExecStart=/home/hyx/work/.venv/bin/python server/app.py
Restart=always
Environment=APP_SECRET=please-change-to-strong ADMIN_PASSWORD=change-this SUPERADMIN_USERNAME=zzh SUPERADMIN_PASSWORD=Zzh08165511 REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD=Zzh08165511
[Install]
WantedBy=multi-user.target

4
frontend/assets/avatars/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# 忽略所有上传的头像文件
*
# 但保留此目录
!.gitignore

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763626721741" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10947" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M935.812877 88.243078h-87.685776V29.784188a29.226886 29.226886 0 1 0-58.453771 0v58.45889H234.331789V29.784188a29.226886 29.226886 0 1 0-58.453771 0v58.45889H88.187123C39.762336 88.243078 0.501347 127.498948 0.501347 175.923735v759.945098c0 48.429907 39.260989 87.685776 87.685776 87.685776h847.625754c48.424787 0 87.685776-39.260989 87.685776-87.685776V175.928854c0-48.424787-39.260989-87.685776-87.685776-87.685776z m29.226886 847.625755a29.232005 29.232005 0 0 1-29.226886 29.232005H88.187123a29.232005 29.232005 0 0 1-29.226886-29.232005V351.300406h906.079526v584.568427z m0-643.027317H58.960237V175.928854a29.226886 29.226886 0 0 1 29.226886-29.226885h87.685776v29.226885a29.226886 29.226886 0 1 0 58.453771 0v-29.226885h555.341541v29.226885a29.226886 29.226886 0 1 0 58.453771 0v-29.226885h87.685776a29.226886 29.226886 0 0 1 29.226885 29.226885V292.841516z" p-id="10948" fill="#ffffff"></path><path d="M512 526.671958c24.179117 0 43.842888-19.663771 43.842888-43.842888A43.842888 43.842888 0 0 0 512 438.986182a43.807052 43.807052 0 0 0-43.842888 43.842888c0 24.173998 19.597218 43.842888 43.842888 43.842888zM745.825323 877.409942c24.184237 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.801932 43.801932 0 0 0-43.842888 43.842888c0.005119 24.179117 19.602337 43.842888 43.842888 43.842888zM512 702.03839c24.179117 0 43.842888-19.658651 43.842888-43.842888 0-24.240551-19.663771-43.837769-43.842888-43.837768a43.801932 43.801932 0 0 0-43.842888 43.837768A43.842888 43.842888 0 0 0 512 702.03839zM745.825323 526.671958c24.184237 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.807052 43.807052 0 0 0-43.842888 43.842888c0.005119 24.173998 19.602337 43.842888 43.842888 43.842888zM745.825323 702.03839c24.184237 0 43.842888-19.658651 43.842888-43.842888 0-24.240551-19.663771-43.837769-43.842888-43.837768a43.801932 43.801932 0 0 0-43.842888 43.837768c0.005119 24.184237 19.602337 43.842888 43.842888 43.842888zM512 877.409942c24.179117 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.801932 43.801932 0 0 0-43.842888 43.842888 43.842888 43.842888 0 0 0 43.842888 43.842888zM278.174677 877.409942c24.179117 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.801932 43.801932 0 0 0-43.842888 43.842888 43.842888 43.842888 0 0 0 43.842888 43.842888zM278.174677 702.03839c24.179117 0 43.842888-19.658651 43.842888-43.842888 0-24.240551-19.663771-43.837769-43.842888-43.837768a43.801932 43.801932 0 0 0-43.842888 43.837768 43.842888 43.842888 0 0 0 43.842888 43.842888zM278.174677 526.671958c24.179117 0 43.842888-19.663771 43.842888-43.842888a43.842888 43.842888 0 0 0-43.842888-43.842888 43.807052 43.807052 0 0 0-43.842888 43.842888c0 24.173998 19.597218 43.842888 43.842888 43.842888z" p-id="10949" fill="#ffffff"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763626025824" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5099" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M819.057629 645.954349l-62.937128 0 0 62.999554 62.937128 0L819.057629 645.954349zM819.057629 771.829612l-62.937128 0 0 62.999554 62.937128 0L819.057629 771.829612zM630.244232 520.078079l-62.938135 0 0 63.00056 62.938135 0L630.244232 520.078079zM630.244232 79.389305 378.494713 79.389305l0 62.938135 251.749519 0L630.244232 79.389305zM284.333689 204.435918c17.270758 0 31.16147-18.747829 31.16147-41.857399L315.495159 58.24715c0-23.048151-13.890712-41.794973-31.16147-41.794973-17.146914 0-31.099044 18.746823-31.099044 41.794973l0 104.332376C253.234645 185.689095 267.186775 204.435918 284.333689 204.435918M724.90164 204.435918c17.576845 0 31.832042-18.747829 31.832042-41.857399L756.733682 58.24715c0-23.048151-14.255197-41.794973-31.832042-41.794973-17.58188 0-31.838083 18.746823-31.838083 41.794973l0 104.332376C693.063556 185.689095 707.31976 204.435918 724.90164 204.435918M630.244232 645.954349l-62.938135 0 0 62.999554 62.938135 0L630.244232 645.954349zM819.057629 520.078079l-62.937128 0 0 63.00056 62.937128 0L819.057629 520.078079zM252.618443 771.829612l-62.939142 0 0 63.058959 62.939142 0L252.618443 771.829612zM252.618443 645.954349l-62.939142 0 0 62.999554 62.939142 0L252.618443 645.954349zM944.932892 79.389305 819.057629 79.389305l0 62.938135 125.87627 0 0 188.815411L63.805046 331.142851 63.805046 142.32744l125.875263 0L189.680309 79.389305 63.805046 79.389305c-65.271041 0-62.938135 64.567243-62.938135 62.938135l0 818.192732c0 61.277814 60.604221 62.938135 62.938135 62.938135l881.128853 0c57.406418 0 62.937128-60.601201 62.937128-62.938135L1007.871027 142.32744C1007.87002 138.057324 1006.21272 79.389305 944.932892 79.389305M944.932892 960.520172 63.805046 960.520172 63.805046 394.080986l881.128853 0L944.933899 960.520172zM630.244232 771.829612l-62.938135 0 0 63.058959 62.938135 0L630.244232 771.829612zM441.433855 645.954349l-62.939142 0 0 62.999554 62.939142 0L441.433855 645.954349zM441.433855 520.078079l-62.939142 0 0 63.00056 62.939142 0L441.433855 520.078079zM441.433855 771.829612l-62.939142 0 0 63.058959 62.939142 0L441.433855 771.829612zM252.618443 520.078079l-62.939142 0 0 63.00056 62.939142 0L252.618443 520.078079z" fill="#272636" p-id="5100"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763642480325" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="22385" data-spm-anchor-id="a313x.search_index.0.i8.510d3a81Nn5QNN" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M485.07804233 555.34391534l364.24370884 250.41244254a464.06874467 464.06874467 0 0 1-375.48791719 189.82773266C218.28958704 995.58409054 11.12158537 791.84818736 11.12158537 540.53686189 11.12158537 289.20919308 218.28958704 85.47328989 473.83383398 85.47328989c3.80799497 0 7.46890001 0.45761314 11.24420835 0.5556731V555.34391534z" fill="#eb7627" p-id="22386" data-spm-anchor-id="a313x.search_index.0.i4.510d3a81Nn5QNN" class=""></path><path d="M1004.12573602 489.97061092H566.79467283V19.26647586c244.08757534 12.61704775 438.19725945 210.66547345 438.19725948 453.85416585 0 5.68747749-0.65373305 11.21152169-0.86619629 16.84996921z" fill="#f4ea2a" p-id="22387" data-spm-anchor-id="a313x.search_index.0.i5.510d3a81Nn5QNN" class=""></path><path d="M1021.3189151 539.00058924a446.43429582 446.43429582 0 0 1-51.67759716 207.91979467L575.97962211 543.74015381V539.00058924h445.33929299z" fill="#d81e06" p-id="22388" data-spm-anchor-id="a313x.search_index.0.i7.510d3a81Nn5QNN" class=""></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

535
frontend/assets/styles.css Normal file
View File

@ -0,0 +1,535 @@
:root{--bg:#0f1216;--surface:#151922;--surface-2:#1b2030;--primary:#4f8cff;--primary-600:#3b71db;--text:#e6e8ee;--text-2:#b3b8c6;--border:#2a3244;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
[data-theme="light"]{--bg:#f5f7fa;--surface:#ffffff;--surface-2:#f8f9fb;--primary:#4f8cff;--primary-600:#3b71db;--text:#1a1d23;--text-2:#6b7280;--border:#e5e7eb;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
*{box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Noto Sans",sans-serif;line-height:1.5}
#app{display:flex;height:100vh;overflow:hidden}
.sidebar{width:240px;min-width:240px;background:linear-gradient(180deg,var(--surface),var(--surface-2));border-right:1px solid var(--border);display:flex;flex-direction:column;height:100vh;overflow:hidden}
.brand{padding:16px 20px;font-weight:700;font-size:18px;color:var(--text)}
.nav{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 8px 16px}
.nav-group{margin:8px 0}
.nav-group-title{padding:8px 12px;color:var(--text-2);font-size:12px;letter-spacing:.04em;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;margin:4px;border-radius:8px;color:var(--text);text-decoration:none;font-size:14px}
.nav-item:hover{background:rgba(79,140,255,.12)}
.nav-item.active{background:rgba(79,140,255,.18);outline:1px solid rgba(79,140,255,.4)}
.nav-item .icon{width:20px;text-align:center}
.nav-item.has-children{flex-direction:column;gap:0;padding:0;background:transparent}
.nav-item-btn{width:100%;display:flex;align-items:center;gap:10px;padding:10px 12px;margin:4px;border-radius:8px;background:transparent;border:0;color:var(--text);cursor:pointer;font-size:14px;font-family:inherit}
.nav-item-btn:hover{background:rgba(79,140,255,.12)}
.nav-children{max-height:0;overflow:hidden;transition:max-height .3s ease;background:transparent;padding:0}
.nav-children.open{max-height:320px;padding:4px 0}
.nav-child{display:flex;align-items:center;gap:8px;padding:8px 12px 8px 16px;margin:2px 8px;border-radius:6px;color:var(--text-2);text-decoration:none;font-size:13px;transition:all 0.2s ease;border-left:2px solid transparent}
.nav-child:hover{color:var(--text);background:rgba(79,140,255,.12);border-left-color:var(--primary);transform:translateX(2px)}
.nav-child.active{color:var(--primary);background:rgba(79,140,255,.15);border-left-color:var(--primary);font-weight:500}
.child-icon{font-size:14px;width:16px;text-align:center;opacity:0.8}
.caret{margin-left:auto;transition:transform .2s ease}
.caret.rotate{transform:rotate(90deg)}
.btn{border:0;border-radius:8px;padding:8px 12px;font-weight:600;cursor:pointer;background:var(--primary);color:#ffffff}
.btn:hover{background:var(--primary-600)}
.btn-secondary{background:#253045;color:var(--text)}
.btn-secondary:hover{background:#2b3750}
[data-theme="light"] .btn-secondary{background:#e5e7eb;color:var(--text)}
[data-theme="light"] .btn-secondary:hover{background:#d1d5db}
.content{flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;overflow:hidden}
.content-header{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);background:var(--surface);flex-shrink:0;position:relative;z-index:10}
#breadcrumb{color:var(--text-2)}
#actions{position:relative;z-index:999;display:flex;align-items:center;gap:8px}
.view{position:relative;padding:20px;flex:1;overflow-y:auto;overflow-x:hidden}
.card{background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:16px}
.grid{display:grid;gap:12px}
.grid.cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid.cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}
.field label{color:var(--text-2);font-size:13px}
.input,select{background:#0c0f14;color:var(--text);border:1px solid var(--border);border-radius:8px;padding:10px}
.input:focus,select:focus{outline:1px solid var(--primary)}
[data-theme="light"] .input,[data-theme="light"] select{background:#ffffff;color:var(--text)}
/* Date input styling */
input[type="date"]{color-scheme:dark}
input[type="date"]::-webkit-calendar-picker-indicator{
filter:invert(1) brightness(1.5) !important;
cursor:pointer !important;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover{
filter:invert(1) brightness(2) !important;
}
/* Audit date pickers - use custom calendar icon */
/* Dark theme - use dark icon */
#audit-date-pdd::-webkit-calendar-picker-indicator,
#audit-date-yt::-webkit-calendar-picker-indicator{
background-image: url('../assets/calendar-dark.svg') !important;
background-size: contain !important;
background-repeat: no-repeat !important;
background-position: center !important;
width: 18px !important;
height: 18px !important;
cursor: pointer !important;
opacity: 0.7 !important;
filter: none !important;
}
#audit-date-pdd::-webkit-calendar-picker-indicator:hover,
#audit-date-yt::-webkit-calendar-picker-indicator:hover{
opacity: 1 !important;
}
/* Light theme - use light icon */
[data-theme="light"] input[type="date"]{color-scheme:light}
[data-theme="light"] input[type="date"]::-webkit-calendar-picker-indicator{
filter:none !important;
opacity: 0.7 !important;
}
[data-theme="light"] input[type="date"]::-webkit-calendar-picker-indicator:hover{
opacity: 1 !important;
}
[data-theme="light"] #audit-date-pdd::-webkit-calendar-picker-indicator,
[data-theme="light"] #audit-date-yt::-webkit-calendar-picker-indicator{
background-image: url('../assets/calendar.svg') !important;
}
.row{display:flex;gap:12px;flex-wrap:wrap}
.row .col{flex:1;min-width:240px}
.actions{display:flex;gap:8px}
.badge{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:4px 10px;font-size:12px;background:#1f2535;color:var(--text-2)}
.badge.success{background:rgba(34,197,94,.15);color:#8be59f}
.badge.warning{background:rgba(245,158,11,.15);color:#f8cf88}
.badge.danger{background:rgba(239,68,68,.15);color:#f4a3a3}
[data-theme="light"] .badge{background:#e5e7eb;color:#374151}
[data-theme="light"] .badge.success{background:rgba(34,197,94,.15);color:#16a34a}
[data-theme="light"] .badge.warning{background:rgba(245,158,11,.15);color:#d97706}
[data-theme="light"] .badge.danger{background:rgba(239,68,68,.15);color:#dc2626}
.list{list-style:none;padding:0;margin:0}
.list li{display:flex;justify-content:space-between;padding:8px 10px;border-bottom:1px dashed var(--border)}
.list::-webkit-scrollbar,.nav::-webkit-scrollbar,.view::-webkit-scrollbar{width:8px;height:8px}
.list::-webkit-scrollbar-track,.nav::-webkit-scrollbar-track,.view::-webkit-scrollbar-track{background:transparent}
.list::-webkit-scrollbar-thumb,.nav::-webkit-scrollbar-thumb,.view::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:4px}
.list::-webkit-scrollbar-thumb:hover,.nav::-webkit-scrollbar-thumb:hover,.view::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.15)}
[data-theme="light"] .list::-webkit-scrollbar-thumb,[data-theme="light"] .nav::-webkit-scrollbar-thumb,[data-theme="light"] .view::-webkit-scrollbar-thumb{background:rgba(0,0,0,.1)}
[data-theme="light"] .list::-webkit-scrollbar-thumb:hover,[data-theme="light"] .nav::-webkit-scrollbar-thumb:hover,[data-theme="light"] .view::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.15)}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--surface-2);color:var(--text);padding:10px 14px;border-radius:10px;border:1px solid var(--border);opacity:0;pointer-events:none;transition:opacity .2s ease}
.toast.show{opacity:1}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center}
.overlay.hidden{display:none}
.loader{display:flex;gap:8px}
.loader .dot{width:10px;height:10px;border-radius:999px;background:var(--primary);animation:bounce .9s ease infinite}
.loader .dot:nth-child(2){animation-delay:.15s}
.loader .dot:nth-child(3){animation-delay:.3s}
@keyframes bounce{0%,100%{transform:translateY(0);opacity:.7}50%{transform:translateY(-8px);opacity:1}}
.fade-enter{opacity:0;transform:translateY(6px)}
.fade-enter-active{transition:opacity .2s ease,transform .2s ease;opacity:1;transform:translateY(0)}
.error{color:#ffb4b4}
.menu-toggle{display:none}
@media(max-width:1024px){.grid.cols-3{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:768px){.sidebar{position:fixed;top:0;bottom:0;left:-240px;z-index:20;transition:left .2s ease}.sidebar.open{left:0}.content{margin-left:0}.menu-toggle{display:inline-flex;align-items:center;gap:8px;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px}}
/* File upload status styles */
#ship-file-status.success,
#defect-file-status.success,
#ship-manual-status.success {
color: var(--success);
font-weight: 500;
}
#ship-file-status.error,
#defect-file-status.error,
#ship-manual-status.error {
color: var(--danger);
font-weight: 500;
}
/* Format requirements box */
.format-requirements {
margin-bottom: 12px;
padding: 12px;
background: rgba(79, 140, 255, 0.08);
border: 1px solid rgba(79, 140, 255, 0.2);
border-radius: 8px;
font-size: 13px;
color: var(--text);
}
[data-theme="light"] .format-requirements {
background: rgba(79, 140, 255, 0.06);
border: 1px solid rgba(79, 140, 255, 0.15);
}
.format-requirements > div {
line-height: 1.6;
}
.actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.col {
flex: 1;
min-width: 0;
}
/* Shipment query result styles */
.result-card {
margin-top: 16px;
padding: 16px;
border-radius: 8px;
border: 1px solid var(--border);
}
.result-card.success {
background: rgba(34, 197, 94, 0.1);
border-color: var(--success);
}
.result-card.error {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger);
}
.result-title {
font-weight: 600;
font-size: 16px;
margin-bottom: 12px;
}
.result-card.success .result-title {
color: var(--success);
}
.result-card.error .result-title {
color: var(--danger);
}
.result-item {
padding: 6px 0;
font-size: 14px;
}
.result-item .label {
font-weight: 500;
color: var(--text-2);
margin-right: 8px;
}
#query-result .error {
color: var(--danger);
padding: 12px;
background: rgba(239, 68, 68, 0.1);
border-radius: 6px;
margin-top: 12px;
}
/* User menu styles */
.user-menu-container {
position: relative;
display: inline-flex;
align-items: center;
}
.user-avatar-btn {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 12px;
cursor: pointer;
color: var(--text);
transition: all 0.2s ease;
font-family: inherit;
font-size: 14px;
}
.user-avatar-btn:hover {
background: rgba(79, 140, 255, 0.1);
border-color: var(--primary);
}
.user-avatar-img {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
background: var(--surface-2);
}
.user-name-display {
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
[data-theme="light"] .user-dropdown {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.user-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
cursor: pointer;
color: var(--text);
transition: background 0.2s ease;
font-size: 14px;
}
.user-dropdown-item:hover {
background: rgba(79, 140, 255, 0.1);
}
.user-dropdown-item .dropdown-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* Notification bell styles */
.notification-bell {
position: relative;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
color: var(--text);
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
z-index: 999;
pointer-events: auto;
}
.notification-bell:hover {
background: rgba(79, 140, 255, 0.1);
border-color: var(--primary);
}
.notification-bell .bell-icon {
font-size: 18px;
}
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
background: var(--danger);
color: white;
border-radius: 999px;
padding: 2px 6px;
font-size: 11px;
font-weight: 600;
min-width: 18px;
text-align: center;
z-index: 1000;
pointer-events: none;
}
/* Notification panel */
.notification-panel {
position: fixed;
top: 60px;
right: 20px;
width: 380px;
max-height: 500px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
display: flex;
flex-direction: column;
}
[data-theme="light"] .notification-panel {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.notification-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border);
}
.notification-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.notification-actions {
display: flex;
gap: 8px;
}
.btn-text {
background: transparent;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 13px;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
}
.btn-text:hover {
background: rgba(79, 140, 255, 0.1);
}
.btn-text.btn-danger {
color: var(--danger);
}
.btn-text.btn-danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.notification-list {
flex: 1;
overflow-y: auto;
max-height: 440px;
}
.notification-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.2s ease;
position: relative;
padding-left: 20px;
}
.notification-item:hover {
background: rgba(79, 140, 255, 0.05);
}
.notification-item.unread {
background: rgba(79, 140, 255, 0.08);
}
.notification-item.unread::before {
content: '';
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
background: var(--primary);
border-radius: 999px;
}
.notification-username {
font-weight: 600;
color: var(--text);
font-size: 14px;
}
.notification-action {
color: var(--text-2);
font-size: 13px;
margin-top: 2px;
}
.notification-detail {
color: var(--text-2);
font-size: 12px;
margin-top: 4px;
}
.notification-time {
color: var(--text-2);
font-size: 11px;
margin-top: 4px;
}
.notification-empty {
padding: 40px 20px;
text-align: center;
color: var(--text-2);
font-size: 14px;
}
@media(max-width:768px) {
.notification-panel {
right: 10px;
left: 10px;
width: auto;
}
}
/* Dashboard metrics - 4 columns always */
.dashboard-metrics-4col {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
@media(max-width: 768px) {
.dashboard-metrics-4col {
gap: 6px;
}
.dashboard-metrics-4col .card {
padding: 8px !important;
}
.dashboard-metrics-4col .badge {
font-size: 14px !important;
padding: 4px 8px !important;
}
}
@media(max-width: 480px) {
.dashboard-metrics-4col {
gap: 4px;
}
.dashboard-metrics-4col .card {
padding: 6px !important;
}
.dashboard-metrics-4col .badge {
font-size: 12px !important;
padding: 3px 6px !important;
}
}
/* Dashboard icon - SVG */
.icon-dashboard {
width: 20px;
height: 20px;
display: inline-block;
background-image: url('../assets/dashboard.svg');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

173
frontend/index.html Normal file
View File

@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>韬智生产管理系统</title>
<link rel="icon" type="image/svg+xml" href="./assets/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="./assets/styles.css" />
</head>
<body>
<div id="app">
<aside class="sidebar">
<div class="brand">韬智生产管理系统</div>
<nav class="nav">
<div class="nav-group">
<div class="nav-group-title">概览</div>
<a href="#/dashboard" class="nav-item" data-route="dashboard">
<span class="icon icon-dashboard"></span>
<span class="text">仪表盘</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-title">数据上传</div>
<div class="nav-item has-children" data-expand="upload">
<button class="nav-item-btn">
<span class="icon">⬆️</span>
<span class="text">上传</span>
<span class="caret"></span>
</button>
<div class="nav-children" data-parent="upload">
<a href="#/upload/mac" class="nav-child" data-route="upload-mac">
<span class="child-icon">📱</span>
<span>MAC与批次</span>
</a>
<a href="#/upload/stats" class="nav-child" data-route="upload-stats">
<span class="child-icon">📈</span>
<span>良/不良统计</span>
</a>
<a href="#/upload/repairs" class="nav-child" data-route="upload-repairs">
<span class="child-icon">🔧</span>
<span>返修记录</span>
</a>
<a href="#/upload/shipments" class="nav-child" data-route="upload-shipments">
<span class="child-icon">📦</span>
<span>发货记录</span>
</a>
</div>
</div>
</div>
<div class="nav-group">
<div class="nav-group-title">查询</div>
<a href="#/shipments/query" class="nav-item" data-route="shipments-query">
<span class="icon">🔍</span>
<span class="text">出货查询</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-title">扩展采集</div>
<div class="nav-item has-children" data-expand="collect">
<button class="nav-item-btn">
<span class="icon">🛠️</span>
<span class="text">采集</span>
<span class="caret"></span>
</button>
<div class="nav-children" data-parent="collect">
<a href="#/devices" class="nav-child" data-route="devices">
<span class="child-icon">💻</span>
<span>设备状态</span>
</a>
<a href="#/environment" class="nav-child" data-route="environment">
<span class="child-icon">🌡️</span>
<span>环境参数</span>
</a>
<a href="#/personnel" class="nav-child" data-route="personnel">
<span class="child-icon">👥</span>
<span>人员信息</span>
</a>
<a href="#/qa" class="nav-child" data-route="qa">
<span class="child-icon"></span>
<span>质检报告</span>
</a>
<a href="#/production" class="nav-child" data-route="production">
<span class="child-icon">⏱️</span>
<span>时间记录</span>
</a>
</div>
</div>
</div>
<div class="nav-group">
<div class="nav-group-title">导出与设置</div>
<a href="#/export" class="nav-item" data-route="export">
<span class="icon">📤</span>
<span class="text">导出</span>
</a>
<a href="#/settings" class="nav-item" data-route="settings">
<span class="icon">⚙️</span>
<span class="text">设置</span>
</a>
</div>
</nav>
</aside>
<main class="content">
<header class="content-header">
<div id="breadcrumb"></div>
<div id="actions">
<button id="notification-bell" class="notification-bell" style="display:none;">
<span class="bell-icon">🔔</span>
<span id="notification-badge" class="notification-badge" style="display:none;">0</span>
</button>
<div class="user-menu-container">
<button id="user-avatar-btn" class="user-avatar-btn">
<img id="user-avatar-img" src="./assets/user-avatar.svg" alt="用户头像" class="user-avatar-img" />
<span id="user-name-display" class="user-name-display">未登录</span>
</button>
<div id="user-dropdown" class="user-dropdown" style="display:none;">
<div class="user-dropdown-item" id="user-dropdown-logout">
<span class="dropdown-icon">🚪</span>
<span>退出登录</span>
</div>
</div>
</div>
<button id="menu-toggle" class="menu-toggle">菜单</button>
</div>
</header>
<section id="view" class="view"></section>
</main>
<!-- 通知面板 -->
<div id="notification-panel" class="notification-panel" style="display:none;">
<div class="notification-header">
<h3>消息通知</h3>
<div class="notification-actions">
<button id="mark-all-read" class="btn-text">全部已读</button>
<button id="delete-read" class="btn-text btn-danger">删除已读</button>
</div>
</div>
<div id="notification-list" class="notification-list">
<!-- 通知列表将在这里动态生成 -->
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<div id="overlay" class="overlay hidden">
<div class="loader">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
<script src="./js/router.js"></script>
<script src="./js/api.js"></script>
<script src="./js/utils/memory-monitor.js"></script>
<script src="./js/components/sidebar.js"></script>
<script src="./js/components/dashboard.js"></script>
<script src="./js/components/login.js"></script>
<script src="./js/components/upload.js"></script>
<script src="./js/components/shipments.js"></script>
<script src="./js/components/shipment-query.js"></script>
<script src="./js/components/defects.js"></script>
<script src="./js/components/devices.js"></script>
<script src="./js/components/environment.js"></script>
<script src="./js/components/personnel.js"></script>
<script src="./js/components/qa.js"></script>
<script src="./js/components/production.js"></script>
<script src="./js/components/export.js"></script>
<script src="./js/components/settings.js"></script>
<script src="./js/components/notifications.js"></script>
<script src="./js/app.js"></script>
</body>
</html>

132
frontend/js/api.js Normal file
View File

@ -0,0 +1,132 @@
const API = (() => {
const base = '/api';
async function request(path, opts = {}) {
const overlay = document.getElementById('overlay');
overlay.classList.remove('hidden');
try {
const res = await fetch(base + path, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
...opts
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (e) {
toast(e.message || '请求失败');
throw e;
} finally {
overlay.classList.add('hidden');
}
}
async function requestQuiet(path, opts = {}) {
// 创建超时控制器
const controller = opts.signal ? null : new AbortController();
const timeoutId = controller ? setTimeout(() => {
controller.abort();
console.warn('[API] 请求超时:', path);
}, 10000) : null; // 10秒超时
try {
const res = await fetch(base + path, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
signal: opts.signal || (controller ? controller.signal : undefined),
...opts
});
if (timeoutId) clearTimeout(timeoutId);
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (e) {
if (timeoutId) clearTimeout(timeoutId);
// 忽略取消的请求
if (e.name === 'AbortError') {
return { list: [] };
}
throw e;
}
}
async function uploadFile(path, formData) {
const overlay = document.getElementById('overlay');
overlay.classList.remove('hidden');
try {
const res = await fetch(base + path, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (e) {
toast(e.message || '上传失败');
throw e;
} finally {
overlay.classList.add('hidden');
}
}
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
return {
login: (username, password) => request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => request('/auth/me'),
logout: () => request('/auth/logout', { method: 'POST' }),
dashboard: () => request('/dashboard'),
overview: () => request('/overview'),
uploadMac: data => request('/upload/mac', { method: 'POST', body: JSON.stringify(data) }),
uploadMacFile: file => {
const fd = new FormData();
fd.append('file', file);
return uploadFile('/upload/mac-file', fd);
},
uploadStats: data => request('/upload/stats', { method: 'POST', body: JSON.stringify(data) }),
uploadRepairs: data => request('/upload/repairs', { method: 'POST', body: JSON.stringify(data) }),
uploadDefects: data => request('/upload/defects', { method: 'POST', body: JSON.stringify(data) }),
uploadDefectsFile: file => {
const fd = new FormData();
fd.append('file', file);
return uploadFile('/upload/defects-file', fd);
},
uploadShipments: data => request('/upload/shipments', { method: 'POST', body: JSON.stringify(data) }),
devices: () => request('/collect/devices'),
environment: () => request('/collect/environment'),
personnel: () => request('/collect/personnel'),
qa: () => request('/collect/qa'),
production: () => request('/collect/production'),
addPersonnel: (name, role) => request('/collect/personnel', { method: 'POST', body: JSON.stringify({ name, role }) }),
listMac: () => request('/list/mac'),
listStats: () => request('/list/stats'),
listRepairs: () => request('/list/repairs'),
listDefects: () => request('/list/defects'),
listShipments: () => request('/list/shipments'),
auditPdd: (params={}) => request('/audit/pdd' + buildQuery(params)),
auditYt: (params={}) => request('/audit/yt' + buildQuery(params)),
auditPddQuiet: (params={}) => requestQuiet('/audit/pdd' + buildQuery(params)),
auditYtQuiet: (params={}) => requestQuiet('/audit/yt' + buildQuery(params)),
exportExcel: params => request('/export/excel', { method: 'POST', body: JSON.stringify(params) }),
exportPdf: params => request('/export/pdf', { method: 'POST', body: JSON.stringify(params) }),
toast,
adminUsers: () => request('/admin/users'),
resetPassword: (username, new_password) => request('/admin/reset-password', { method: 'POST', body: JSON.stringify({ username, new_password }) }),
changePassword: (username, new_password) => request('/admin/change-password', { method: 'POST', body: JSON.stringify({ username, new_password }) }),
clearModule: module => request('/admin/clear', { method: 'POST', body: JSON.stringify({ module }) }),
getNotifications: () => requestQuiet('/notifications'),
getUnreadCount: () => requestQuiet('/notifications/unread-count'),
markNotificationRead: id => requestQuiet('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ id }) }),
markAllNotificationsRead: () => requestQuiet('/notifications/mark-all-read', { method: 'POST' }),
deleteReadNotifications: () => requestQuiet('/notifications/delete-read', { method: 'POST' }),
updateShipmentsPlatform: () => request('/shipments/update-platform', { method: 'POST' })
};
})();
function buildQuery(params){
const q = new URLSearchParams();
if(params.start) q.set('start', params.start);
if(params.end) q.set('end', params.end);
if(params.limit) q.set('limit', params.limit);
if(params.order) q.set('order', params.order);
const s = q.toString();
return s ? ('?' + s) : '';
}

103
frontend/js/app.js Normal file
View File

@ -0,0 +1,103 @@
(() => {
// 应用保存的主题
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
let currentUser = null;
// 更新用户显示
function updateUserDisplay(user) {
const userNameDisplay = document.getElementById('user-name-display');
const userCard = document.getElementById('user-card');
const username = (user && user.username) ? user.username : '未登录';
if (userNameDisplay) userNameDisplay.textContent = username;
if (userCard) userCard.textContent = username;
// 加载用户头像(如果有设置)
const avatarImg = document.getElementById('user-avatar-img');
if (avatarImg && user && user.avatar) {
avatarImg.src = user.avatar;
} else if (avatarImg) {
avatarImg.src = './assets/user-avatar.svg';
}
}
Router.onBeforeEach(async (path) => {
document.getElementById('overlay').classList.remove('hidden');
try {
currentUser = await API.me().catch(() => null);
const publicRoutes = ['/login'];
if ((!currentUser || !currentUser.username) && !publicRoutes.includes(path)) {
location.hash = '#/login';
}
} catch(e) {}
});
Router.onAfterEach(async (path) => {
document.getElementById('overlay').classList.add('hidden');
updateUserDisplay(currentUser);
// 初始化通知系统(仅对超级管理员)
if (currentUser && currentUser.role === 'superadmin' && window.NotificationSystem) {
window.NotificationSystem.init();
}
});
Router.init();
API.me().then(user => {
currentUser = user;
updateUserDisplay(user);
// 初始化通知系统(仅对超级管理员)
if (user && user.role === 'superadmin' && window.NotificationSystem) {
window.NotificationSystem.init();
}
}).catch(()=>{});
// 用户头像按钮点击事件 - 显示/隐藏下拉菜单
const userAvatarBtn = document.getElementById('user-avatar-btn');
const userDropdown = document.getElementById('user-dropdown');
userAvatarBtn?.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = userDropdown.style.display !== 'none';
userDropdown.style.display = isVisible ? 'none' : 'block';
});
// 点击页面其他地方关闭下拉菜单
document.addEventListener('click', (e) => {
if (userDropdown && !userDropdown.contains(e.target) && e.target !== userAvatarBtn) {
userDropdown.style.display = 'none';
}
});
// 退出登录
const logoutBtn = document.getElementById('user-dropdown-logout');
logoutBtn?.addEventListener('click', () => {
// 清理通知系统
if (window.NotificationSystem) {
window.NotificationSystem.cleanup();
}
API.logout()
.then(() => {
currentUser = null;
updateUserDisplay(null);
userDropdown.style.display = 'none';
location.hash = '#/login';
})
.catch(() => {});
});
// 菜单切换
const toggleBtn = document.getElementById('menu-toggle');
const sidebar = document.querySelector('.sidebar');
toggleBtn?.addEventListener('click', () => {
sidebar.classList.toggle('open');
});
// 暴露更新用户显示的函数,供设置页面使用
window.updateUserDisplay = updateUserDisplay;
})();

View File

@ -0,0 +1,464 @@
const Dashboard = (() => {
function metricsCard(title, value, badgeClass) {
return `<div class="card" style="padding:10px">
<div style="display:flex;flex-direction:column;gap:6px;align-items:center;text-align:center">
<div style="font-weight:600;font-size:12px;color:var(--text-2);white-space:nowrap">${title}</div>
<div class="badge ${badgeClass}" style="font-size:16px;font-weight:700;padding:6px 12px">${value}</div>
</div>
</div>`;
}
// 清理函数
const cleanup = () => {
if(window.__auditTimer){
clearInterval(window.__auditTimer);
window.__auditTimer = null;
}
// 取消未完成的请求
if(window.__auditAbortController){
window.__auditAbortController.abort();
window.__auditAbortController = null;
}
// 清理Canvas事件监听器
const canvas = document.getElementById('trend-chart');
if(canvas){
canvas.onmousemove = null;
canvas.onmouseleave = null;
}
// 清理日期选择器事件
const pddDateEl = document.getElementById('audit-date-pdd');
const ytDateEl = document.getElementById('audit-date-yt');
if(pddDateEl) pddDateEl.onchange = null;
if(ytDateEl) ytDateEl.onchange = null;
// 清理全局变量
window.__auditBusy = false;
window.__pddParams = null;
window.__ytParams = null;
};
Router.onBeforeEach((path)=>{
if(path !== '/dashboard'){
cleanup();
}
});
async function render() {
const [dRes,pRes,yRes] = await Promise.allSettled([
API.dashboard(),
API.auditPddQuiet(),
API.auditYtQuiet()
]);
const data = dRes.status==='fulfilled' ? dRes.value : { goodRate: '—', shipments: '—', defects: '—', badCount: '—' };
const pdd = pRes.status==='fulfilled' ? pRes.value : { list: [] };
const yt = yRes.status==='fulfilled' ? yRes.value : { list: [] };
const pddList = (pdd.list||[]).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytList = (yt.list||[]).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
setTimeout(()=>{
// 清理旧的定时器和事件
if(window.__auditTimer){
clearInterval(window.__auditTimer);
window.__auditTimer = null;
}
// 清理旧的事件监听器
const oldCanvas = document.getElementById('trend-chart');
if(oldCanvas){
oldCanvas.onmousemove = null;
oldCanvas.onmouseleave = null;
}
window.__pddParams = window.__pddParams || {};
window.__ytParams = window.__ytParams || {};
window.__auditBusy=false;
const toEpoch=(s)=>{try{if(!s)return null;let t=s.trim();
if(/Z$/.test(t) || /[\+\-]\d{2}:\d{2}$/.test(t)) return Date.parse(t);
t=t.replace(/\//g,'-');
if(t.includes('T')){
if(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(t)) t = t + ':00';
t = t + (function(){const offMin=-new Date().getTimezoneOffset();const sign=offMin>=0?'+':'-';const hh=String(Math.floor(Math.abs(offMin)/60)).padStart(2,'0');const mm=String(Math.abs(offMin)%60).padStart(2,'0');return sign+hh+':'+mm;})()
}else{
if(/^\d{4}-\d{2}-\d{2}$/.test(t)) t = t + ' 00:00:00';
if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(t)) t = t + ':00';
t = t.replace(' ', 'T') + (function(){const offMin=-new Date().getTimezoneOffset();const sign=offMin>=0?'+':'-';const hh=String(Math.floor(Math.abs(offMin)/60)).padStart(2,'0');const mm=String(Math.abs(offMin)%60).padStart(2,'0');return sign+hh+':'+mm;})()
}
return Date.parse(t);
}catch(e){return null}};
// 获取当前主题的颜色
const getThemeColors = () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
return {
bg: isLight ? '#f8f9fb' : '#0c0f14',
grid: isLight ? '#e5e7eb' : '#1a1f28',
text: isLight ? '#6b7280' : '#6b7280',
textLabel: isLight ? '#1a1d23' : '#e5e7eb'
};
};
// 绘制趋势图
let chartData = null;
const drawTrendChart = (pddData, ytData) => {
const canvas = document.getElementById('trend-chart');
if(!canvas) return;
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
const colors = getThemeColors();
// 处理高分辨率屏幕,避免字体模糊
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.scale(dpr, dpr);
// 获取最近30天的日期
const days = [];
for(let i = 29; i >= 0; i--){
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().split('T')[0]);
}
// 统计每天的数据去重MAC地址
const countByDay = (list) => {
const uniqueMacsByDay = {};
days.forEach(d => uniqueMacsByDay[d] = new Set());
(list||[]).forEach(r => {
if(r.ts_cn && r.mac){
const date = r.ts_cn.split(' ')[0];
if(uniqueMacsByDay[date] !== undefined) {
uniqueMacsByDay[date].add(r.mac);
}
}
});
return days.map(d => uniqueMacsByDay[d].size);
};
const pddCounts = countByDay(pddData);
const ytCounts = countByDay(ytData);
const maxCount = Math.max(...pddCounts, ...ytCounts, 1);
// 绘制参数(使用逻辑尺寸而非物理像素)
const padding = {left: 40, right: 20, top: 20, bottom: 30};
const chartWidth = rect.width - padding.left - padding.right;
const chartHeight = rect.height - padding.top - padding.bottom;
// 保存图表数据供鼠标事件使用
chartData = {days, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight};
// 清空画布
ctx.fillStyle = colors.bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 绘制网格线
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
for(let i = 0; i <= 4; i++){
const y = padding.top + (chartHeight / 4) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(rect.width - padding.right, y);
ctx.stroke();
}
// 绘制Y轴刻度
ctx.fillStyle = colors.text;
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
for(let i = 0; i <= 4; i++){
const value = Math.round(maxCount * (4 - i) / 4);
const y = padding.top + (chartHeight / 4) * i;
ctx.fillText(value.toString(), padding.left - 8, y + 4);
}
// 绘制X轴标签每5天显示一个
ctx.textAlign = 'center';
days.forEach((day, i) => {
if(i % 5 === 0 || i === days.length - 1){
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const label = day.slice(5);
ctx.fillText(label, x, rect.height - 8);
}
});
// 绘制折线
const drawLine = (counts, color) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
counts.forEach((count, i) => {
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
if(i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// 绘制数据点
ctx.fillStyle = color;
counts.forEach((count, i) => {
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
});
};
drawLine(pddCounts, '#f59e0b');
drawLine(ytCounts, '#3b82f6');
// 绘制图例
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = '#f59e0b';
ctx.fillRect(padding.left, 5, 12, 12);
ctx.fillStyle = colors.textLabel;
ctx.fillText('拼多多', padding.left + 18, 15);
ctx.fillStyle = '#3b82f6';
ctx.fillRect(padding.left + 80, 5, 12, 12);
ctx.fillStyle = colors.textLabel;
ctx.fillText('圆通', padding.left + 98, 15);
};
// 初始绘制
drawTrendChart(pdd.list, yt.list);
// 添加鼠标悬停事件
const canvas = document.getElementById('trend-chart');
const tooltip = document.getElementById('chart-tooltip');
if(canvas && tooltip){
canvas.onmousemove = (e) => {
if(!chartData) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const {days, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight} = chartData;
// 查找最近的数据点
let nearestIndex = -1;
let minDist = 15;
for(let i = 0; i < days.length; i++){
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const pddY = padding.top + chartHeight - (pddCounts[i] / maxCount) * chartHeight;
const ytY = padding.top + chartHeight - (ytCounts[i] / maxCount) * chartHeight;
const distPdd = Math.sqrt((mouseX - x) ** 2 + (mouseY - pddY) ** 2);
const distYt = Math.sqrt((mouseX - x) ** 2 + (mouseY - ytY) ** 2);
const dist = Math.min(distPdd, distYt);
if(dist < minDist){
minDist = dist;
nearestIndex = i;
}
}
if(nearestIndex >= 0){
const date = days[nearestIndex];
const pddCount = pddCounts[nearestIndex];
const ytCount = ytCounts[nearestIndex];
tooltip.innerHTML = `<div style="margin-bottom:2px">${date}</div><div style="color:#f59e0b">拼多多: ${pddCount}</div><div style="color:#3b82f6">圆通: ${ytCount}</div>`;
tooltip.style.display = 'block';
// 获取提示框宽度以便放在鼠标左侧
const tooltipWidth = tooltip.offsetWidth || 100;
tooltip.style.left = (e.clientX - rect.left - tooltipWidth - 10) + 'px';
tooltip.style.top = (e.clientY - rect.top - 10) + 'px';
} else {
tooltip.style.display = 'none';
}
};
canvas.onmouseleave = () => {
tooltip.style.display = 'none';
};
}
// 刷新审计列表的函数
const refreshAuditLists = async() => {
if(window.__auditBusy) return;
window.__auditBusy=true;
const pdd2=await API.auditPddQuiet(window.__pddParams).catch(()=>({list:[]}));
const yt2=await API.auditYtQuiet(window.__ytParams).catch(()=>({list:[]}));
const sP=toEpoch(window.__pddParams.start), eP=toEpoch(window.__pddParams.end);
const sY=toEpoch(window.__ytParams.start), eY=toEpoch(window.__ytParams.end);
const pddView=(pdd2.list||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sP==null||t>=sP)&&(eP==null||t<=eP);});
const ytView=(yt2.list||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sY==null||t>=sY)&&(eY==null||t<=eY);});
const pddEls=pddView.map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytEls=ytView.map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const p=document.getElementById('audit-pdd'); if(p) p.innerHTML=pddEls;
const y=document.getElementById('audit-yt'); if(y) y.innerHTML=ytEls;
window.__auditBusy=false;
};
// 设置日期筛选事件
const q = id => document.getElementById(id);
const dateToRange = d => {
if(!d) return {};
return { start: d + ' 00:00:00', end: d + ' 23:59:59' };
};
const pddDateEl=q('audit-date-pdd');
const ytDateEl=q('audit-date-yt');
if(pddDateEl){
pddDateEl.onchange=()=>{
const d=pddDateEl.value;
window.__pddParams = dateToRange(d);
refreshAuditLists(); // 立即刷新
};
}
if(ytDateEl){
ytDateEl.onchange=()=>{
const d=ytDateEl.value;
window.__ytParams = dateToRange(d);
refreshAuditLists(); // 立即刷新
};
}
// 资源管理:缓存数据(已禁用自动清理)
window.__auditCache = window.__auditCache || {pdd: [], yt: []};
// 优化的刷新函数:一次请求同时更新趋势图和列表
const refreshAll = async() => {
if(window.__auditBusy) {
console.log('[Dashboard] 上次请求还在进行中,跳过本次刷新');
return;
}
// 检查是否还在dashboard页面
const currentPath = location.hash.replace('#', '') || '/dashboard';
if(currentPath !== '/dashboard'){
if(window.__auditTimer){
clearInterval(window.__auditTimer);
window.__auditTimer = null;
}
return;
}
// 取消之前的请求
if(window.__auditAbortController){
window.__auditAbortController.abort();
}
window.__auditAbortController = new AbortController();
window.__auditBusy=true;
const startTime = Date.now();
// 设置超时保护15秒后强制重置busy状态
const timeoutId = setTimeout(() => {
console.warn('[Dashboard] 请求超时,强制重置状态');
window.__auditBusy = false;
if(window.__auditAbortController){
window.__auditAbortController.abort();
window.__auditAbortController = null;
}
}, 15000);
try {
// 请求全部数据不限制传递AbortController信号
const signal = window.__auditAbortController.signal;
const [pddRes, ytRes] = await Promise.all([
fetch('/api/audit/pdd', {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
signal
}).then(r => r.ok ? r.json() : {list:[]}).catch((e)=>{
if(e.name === 'AbortError') console.log('[Dashboard] PDD请求被取消');
return {list:[]};
}),
fetch('/api/audit/yt', {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
signal
}).then(r => r.ok ? r.json() : {list:[]}).catch((e)=>{
if(e.name === 'AbortError') console.log('[Dashboard] YT请求被取消');
return {list:[]};
})
]);
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
if(duration > 3000){
console.warn('[Dashboard] 请求耗时过长:', duration, 'ms');
}
// 再次检查页面,避免切换后更新
if(location.hash.replace('#', '') !== '/dashboard'){
return;
}
// 缓存数据只保留最近30天的数据以节省内存
const filterRecent30Days = (list) => {
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
return (list || []).filter(r => {
if(!r.ts_cn) return false;
const t = toEpoch(r.ts_cn);
return t && t >= cutoff;
});
};
window.__auditCache.pdd = filterRecent30Days(pddRes.list);
window.__auditCache.yt = filterRecent30Days(ytRes.list);
// 更新趋势图
drawTrendChart(window.__auditCache.pdd, window.__auditCache.yt);
// 更新列表应用筛选只显示前100条
const sP=toEpoch(window.__pddParams.start), eP=toEpoch(window.__pddParams.end);
const sY=toEpoch(window.__ytParams.start), eY=toEpoch(window.__ytParams.end);
const pddView=(window.__auditCache.pdd||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sP==null||t>=sP)&&(eP==null||t<=eP);});
const ytView=(window.__auditCache.yt||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sY==null||t>=sY)&&(eY==null||t<=eY);});
const pddEls=pddView.slice(0, 100).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const ytEls=ytView.slice(0, 100).map(r=>`<li><span>${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
const p=document.getElementById('audit-pdd'); if(p) p.innerHTML=pddEls;
const y=document.getElementById('audit-yt'); if(y) y.innerHTML=ytEls;
} catch(e) {
clearTimeout(timeoutId);
if(e.name !== 'AbortError'){
console.error('更新审计数据失败:', e);
}
} finally {
window.__auditBusy=false;
window.__auditAbortController = null;
}
};
// 刷新间隔10秒
window.__auditTimer=setInterval(refreshAll, 10000);
},0);
return `
<div style="display:flex;flex-direction:column;height:100%">
<div class="dashboard-metrics-4col" style="flex-shrink:0">
${metricsCard('直通良品率', data.fpyRate || '—', 'success')}
${metricsCard('良品率', data.goodRate, 'success')}
${metricsCard('发货数量', data.shipments, 'warning')}
${metricsCard('不良数量', data.badCount ?? data.defects, 'danger')}
</div>
<div class="card" style="margin-top:12px;flex-shrink:0">
<div style="font-weight:600;margin-bottom:8px">审计趋势最近30天</div>
<div style="height:220px;background:var(--bg);border:1px solid var(--border);border-radius:8px;position:relative">
<canvas id="trend-chart" style="width:100%;height:100%;cursor:crosshair"></canvas>
<div id="chart-tooltip" style="position:absolute;background:rgba(0,0,0,0.85);color:#fff;padding:6px 10px;border-radius:4px;font-size:12px;pointer-events:none;display:none;white-space:nowrap"></div>
</div>
</div>
<div class="grid cols-2" style="margin-top:12px;flex:1;min-height:0;align-items:stretch">
<div class="card" style="display:flex;flex-direction:column;min-height:0">
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0">
<span>拼多多审计</span>
<input id="audit-date-pdd" type="date" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none" />
</div>
<ul id="audit-pdd" class="list" style="overflow-y:auto;flex:1;min-height:0">${pddList}</ul>
</div>
<div class="card" style="display:flex;flex-direction:column;min-height:0">
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0">
<span>圆通审计</span>
<input id="audit-date-yt" type="date" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none" />
</div>
<ul id="audit-yt" class="list" style="overflow-y:auto;flex:1;min-height:0">${ytList}</ul>
</div>
</div>
</div>
`;
}
Router.register('/dashboard', render);
})();

View File

@ -0,0 +1,3 @@
Router.register('/defects', async () => {
return `<div class="card">此页面占位</div>`;
});

View File

@ -0,0 +1,8 @@
Router.register('/devices', async () => {
const data = await API.devices().catch(() => ({ list: [] }));
const items = (data.list || []).map(d => `<li><span>${d.name || '设备'}</span><span class="badge ${d.status==='ok'?'success':'danger'}">${d.status||'未知'}</span></li>`).join('');
return `<div class="card">
<div style="font-weight:600;margin-bottom:8px">设备状态</div>
<ul class="list">${items || '<li>暂无数据</li>'}</ul>
</div>`;
});

View File

@ -0,0 +1,7 @@
Router.register('/environment', async () => {
const data = await API.environment().catch(() => ({ temp: '—', hum: '—' }));
return `<div class="grid cols-2">
<div class="card"><div style="font-weight:600">温度</div><div class="badge">${data.temp}</div></div>
<div class="card"><div style="font-weight:600">湿度</div><div class="badge">${data.hum}</div></div>
</div>`;
});

View File

@ -0,0 +1,472 @@
const Export = (() => {
async function render() {
setTimeout(bindEvents, 0);
return `
<div class="card">
<div style="font-weight:600;margin-bottom:16px;font-size:16px">📤 数据导出</div>
<div class="field">
<label>选择要导出的数据类型</label>
<select id="export-type" class="input">
<option value="stats">/不良统计</option>
<option value="mac">MAC与批次</option>
<option value="repairs">返修记录</option>
<option value="defects">不良明细</option>
<option value="shipments">发货记录</option>
<option value="devices">设备状态</option>
<option value="environment">环境参数</option>
<option value="personnel">人员信息</option>
<option value="qa">质检报告</option>
<option value="production">时间记录</option>
</select>
</div>
<div class="field">
<label>导出格式</label>
<div style="display:flex;gap:12px;margin-top:8px">
<button class="btn" id="export-excel" style="flex:1">
<span>📊</span>
<span>导出为 Excel</span>
</button>
<button class="btn btn-secondary" id="export-pdf" style="flex:1">
<span>📄</span>
<span>导出为 PDF</span>
</button>
</div>
</div>
<div id="export-status" style="margin-top:16px;padding:12px;border-radius:8px;display:none"></div>
</div>
<div class="card" style="margin-top:12px">
<div style="font-weight:600;margin-bottom:12px">📋 数据预览</div>
<div id="export-preview" style="max-height:400px;overflow-y:auto">
<div style="color:var(--text-2);text-align:center;padding:40px">请选择数据类型查看预览</div>
</div>
</div>
`;
}
function bindEvents() {
const typeSelect = document.getElementById('export-type');
const excelBtn = document.getElementById('export-excel');
const pdfBtn = document.getElementById('export-pdf');
// 类型改变时更新预览
if (typeSelect) {
typeSelect.addEventListener('change', updatePreview);
// 初始加载预览
updatePreview();
}
// Excel导出
if (excelBtn) {
excelBtn.addEventListener('click', async () => {
const type = typeSelect.value;
await exportData(type, 'excel');
});
}
// PDF导出
if (pdfBtn) {
pdfBtn.addEventListener('click', async () => {
const type = typeSelect.value;
await exportData(type, 'pdf');
});
}
}
async function updatePreview() {
const type = document.getElementById('export-type').value;
const preview = document.getElementById('export-preview');
try {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">加载中...</div>';
let data;
switch(type) {
case 'stats':
data = await API.listStats();
renderStatsPreview(data.list || []);
break;
case 'mac':
data = await API.listMac();
renderMacPreview(data.list || []);
break;
case 'repairs':
data = await API.listRepairs();
renderRepairsPreview(data.list || []);
break;
case 'defects':
data = await API.listDefects();
renderDefectsPreview(data.list || []);
break;
case 'shipments':
data = await API.listShipments();
renderShipmentsPreview(data.list || []);
break;
case 'devices':
data = await API.devices();
renderDevicesPreview(data.list || []);
break;
case 'environment':
data = await API.environment();
renderEnvironmentPreview(data);
break;
case 'personnel':
data = await API.personnel();
renderPersonnelPreview(data.list || []);
break;
case 'qa':
data = await API.qa();
renderQaPreview(data.list || []);
break;
case 'production':
data = await API.production();
renderProductionPreview(data.list || []);
break;
}
} catch (e) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--danger)">加载失败</div>';
}
}
function renderStatsPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">直通良品数</th>
<th style="padding:8px;text-align:left">良品数</th>
<th style="padding:8px;text-align:left">不良品数</th>
<th style="padding:8px;text-align:left">时间</th>
</tr>
</thead>
<tbody>
${list.slice(0, 50).map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.fpy_good || 0}</td>
<td style="padding:8px">${item.good}</td>
<td style="padding:8px">${item.bad}</td>
<td style="padding:8px;font-size:12px;color:var(--text-2)">${new Date(item.ts).toLocaleString('zh-CN')}</td>
</tr>
`).join('')}
</tbody>
</table>
${list.length > 50 ? `<div style="text-align:center;padding:12px;color:var(--text-2);font-size:13px">仅显示前50条导出时将包含全部 ${list.length} 条数据</div>` : ''}
`;
}
function renderMacPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">MAC地址</th>
<th style="padding:8px;text-align:left">批次号</th>
<th style="padding:8px;text-align:left">时间</th>
</tr>
</thead>
<tbody>
${list.slice(0, 50).map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px;font-family:monospace">${item.mac}</td>
<td style="padding:8px">${item.batch}</td>
<td style="padding:8px;font-size:12px;color:var(--text-2)">${new Date(item.ts).toLocaleString('zh-CN')}</td>
</tr>
`).join('')}
</tbody>
</table>
${list.length > 50 ? `<div style="text-align:center;padding:12px;color:var(--text-2);font-size:13px">仅显示前50条导出时将包含全部 ${list.length} 条数据</div>` : ''}
`;
}
function renderRepairsPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">返修数量</th>
<th style="padding:8px;text-align:left">备注</th>
<th style="padding:8px;text-align:left">时间</th>
</tr>
</thead>
<tbody>
${list.slice(0, 50).map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.qty}</td>
<td style="padding:8px">${item.note || '无'}</td>
<td style="padding:8px;font-size:12px;color:var(--text-2)">${new Date(item.ts).toLocaleString('zh-CN')}</td>
</tr>
`).join('')}
</tbody>
</table>
${list.length > 50 ? `<div style="text-align:center;padding:12px;color:var(--text-2);font-size:13px">仅显示前50条导出时将包含全部 ${list.length} 条数据</div>` : ''}
`;
}
function renderDefectsPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">MAC地址</th>
<th style="padding:8px;text-align:left">批次号</th>
<th style="padding:8px;text-align:left">时间</th>
</tr>
</thead>
<tbody>
${list.slice(0, 50).map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px;font-family:monospace">${item.mac}</td>
<td style="padding:8px">${item.batch}</td>
<td style="padding:8px;font-size:12px;color:var(--text-2)">${new Date(item.ts).toLocaleString('zh-CN')}</td>
</tr>
`).join('')}
</tbody>
</table>
${list.length > 50 ? `<div style="text-align:center;padding:12px;color:var(--text-2);font-size:13px">仅显示前50条导出时将包含全部 ${list.length} 条数据</div>` : ''}
`;
}
function renderShipmentsPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">日期</th>
<th style="padding:8px;text-align:left">数量</th>
<th style="padding:8px;text-align:left">收货方</th>
<th style="padding:8px;text-align:left">时间</th>
</tr>
</thead>
<tbody>
${list.slice(0, 50).map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.date}</td>
<td style="padding:8px">${item.qty}</td>
<td style="padding:8px">${item.receiver}</td>
<td style="padding:8px;font-size:12px;color:var(--text-2)">${new Date(item.ts).toLocaleString('zh-CN')}</td>
</tr>
`).join('')}
</tbody>
</table>
${list.length > 50 ? `<div style="text-align:center;padding:12px;color:var(--text-2);font-size:13px">仅显示前50条导出时将包含全部 ${list.length} 条数据</div>` : ''}
`;
}
function renderDevicesPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">设备名称</th>
<th style="padding:8px;text-align:left">状态</th>
</tr>
</thead>
<tbody>
${list.map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.name}</td>
<td style="padding:8px">${item.status}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function renderEnvironmentPreview(data) {
const preview = document.getElementById('export-preview');
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">参数</th>
<th style="padding:8px;text-align:left"></th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">温度</td>
<td style="padding:8px">${data.temp || '—'}</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">湿度</td>
<td style="padding:8px">${data.hum || '—'}</td>
</tr>
</tbody>
</table>
`;
}
function renderPersonnelPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">姓名</th>
<th style="padding:8px;text-align:left">角色</th>
</tr>
</thead>
<tbody>
${list.map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.name}</td>
<td style="padding:8px">${item.role || '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function renderQaPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">标题</th>
<th style="padding:8px;text-align:left">日期</th>
</tr>
</thead>
<tbody>
${list.map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.title}</td>
<td style="padding:8px">${item.date}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function renderProductionPreview(list) {
const preview = document.getElementById('export-preview');
if (list.length === 0) {
preview.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-2)">暂无数据</div>';
return;
}
preview.innerHTML = `
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="background:var(--surface);border-bottom:2px solid var(--border)">
<th style="padding:8px;text-align:left">批次</th>
<th style="padding:8px;text-align:left">时长</th>
</tr>
</thead>
<tbody>
${list.map(item => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${item.batch}</td>
<td style="padding:8px">${item.duration}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function exportData(type, format) {
const statusEl = document.getElementById('export-status');
try {
statusEl.style.display = 'block';
statusEl.style.background = 'rgba(79,140,255,0.1)';
statusEl.style.color = 'var(--primary)';
statusEl.textContent = `正在导出${format.toUpperCase()}...`;
// 调用导出API并下载文件
const endpoint = format === 'excel' ? '/api/export/excel' : '/api/export/pdf';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ type })
});
if (!response.ok) {
throw new Error('导出失败');
}
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `export_${type}_${Date.now()}.${format === 'excel' ? 'xlsx' : 'pdf'}`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, '');
}
}
// 下载文件
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
statusEl.style.background = 'rgba(34,197,94,0.1)';
statusEl.style.color = 'var(--success)';
statusEl.textContent = `${format.toUpperCase()}导出成功!文件已下载`;
setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
} catch (e) {
statusEl.style.background = 'rgba(239,68,68,0.1)';
statusEl.style.color = 'var(--danger)';
statusEl.textContent = `✗ 导出失败: ${e.message}`;
}
}
Router.register('/export', render);
})();

View File

@ -0,0 +1,25 @@
Router.register('/login', async () => {
setTimeout(() => {
const btn = document.getElementById('login-btn');
btn?.addEventListener('click', async () => {
const u = document.getElementById('login-username').value;
const p = document.getElementById('login-password').value;
try {
await API.login(u, p);
await API.me().then(user => {
const info = document.getElementById('user-info');
if (info) info.textContent = user?.username || '未登录';
}).catch(()=>{});
location.href = '#/dashboard';
} catch(e) {}
});
}, 0);
return `<div class="grid cols-2">
<div class="card">
<div style="font-weight:600;margin-bottom:8px">管理员登录</div>
<div class="field"><label>用户名</label><input id="login-username" class="input" /></div>
<div class="field"><label>密码</label><input id="login-password" type="password" class="input" /></div>
<div class="actions"><button id="login-btn" class="btn">登录</button></div>
</div>
</div>`;
});

View File

@ -0,0 +1,255 @@
(() => {
let notificationInterval = null;
let isOpen = false;
let isInitialized = false;
// 格式化时间
function formatTime(ts) {
if (!ts) return '';
try {
// 解析ISO格式的时间字符串已经是北京时间
const date = new Date(ts);
const now = new Date();
// 计算时间差(毫秒)
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
// 超过7天显示完整日期
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
console.error('时间格式化错误:', e, ts);
return ts;
}
}
// 获取操作的中文描述
function getActionText(action) {
const actionMap = {
'批量上传MAC文件': '批量上传了MAC文件',
'批量上传发货记录文件': '批量上传了发货记录',
'添加人员信息': '添加了人员信息',
'上传发货记录': '上传了发货记录',
'上传MAC与批次': '上传了MAC与批次',
'批量上传不良明细文件': '批量上传了不良明细',
'上传返修记录': '上传了返修记录',
'上传良/不良统计': '上传了良/不良统计',
'上传不良明细': '上传了不良明细'
};
return actionMap[action] || action;
}
// 更新未读数量
async function updateUnreadCount() {
try {
const data = await API.getUnreadCount();
const badge = document.getElementById('notification-badge');
const count = data.count || 0;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
} catch (e) {
console.error('获取未读数量失败:', e);
}
}
// 加载通知列表
async function loadNotifications() {
try {
const data = await API.getNotifications();
const list = data.list || [];
const container = document.getElementById('notification-list');
if (list.length === 0) {
container.innerHTML = '<div class="notification-empty">暂无消息通知</div>';
return;
}
container.innerHTML = list.map(item => `
<div class="notification-item ${item.read ? '' : 'unread'}" data-id="${item.id}">
<div class="notification-username">${item.username || '用户'}</div>
<div class="notification-action">${getActionText(item.action)}</div>
${item.detail ? `<div class="notification-detail">${item.detail}</div>` : ''}
<div class="notification-time">${formatTime(item.ts)}</div>
</div>
`).join('');
// 为每个通知项添加点击事件
container.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', async () => {
const id = item.dataset.id;
if (item.classList.contains('unread')) {
try {
await API.markNotificationRead(id);
item.classList.remove('unread');
await updateUnreadCount();
} catch (e) {
console.error('标记已读失败:', e);
}
}
});
});
} catch (e) {
console.error('加载通知失败:', e);
}
}
// 切换通知面板
function togglePanel() {
const panel = document.getElementById('notification-panel');
if (!panel) {
console.error('[Notifications] 找不到面板元素');
return;
}
isOpen = !isOpen;
console.log('[Notifications] 面板状态:', isOpen ? '打开' : '关闭');
if (isOpen) {
panel.style.display = 'flex';
loadNotifications();
} else {
panel.style.display = 'none';
}
}
// 初始化通知系统
async function initNotifications() {
// 防止重复初始化
if (isInitialized) {
console.log('[Notifications] 已经初始化,跳过');
return;
}
try {
// 检查用户角色
const user = await API.me();
if (user.role !== 'superadmin') {
console.log('[Notifications] 非超级管理员,不显示通知');
return; // 只有超级管理员才显示通知铃铛
}
// 显示通知铃铛
const bell = document.getElementById('notification-bell');
if (!bell) {
console.error('[Notifications] 找不到铃铛元素');
return;
}
bell.style.display = 'inline-flex';
console.log('[Notifications] 铃铛已显示');
// 移除旧的事件监听器(如果有)
const newBell = bell.cloneNode(true);
bell.parentNode.replaceChild(newBell, bell);
// 绑定铃铛点击事件
newBell.addEventListener('click', (e) => {
console.log('[Notifications] 铃铛被点击');
e.preventDefault();
e.stopPropagation();
togglePanel();
});
// 绑定全部标记为已读按钮(移除旧的事件监听器)
const markAllBtn = document.getElementById('mark-all-read');
if (markAllBtn) {
const newMarkAllBtn = markAllBtn.cloneNode(true);
markAllBtn.parentNode.replaceChild(newMarkAllBtn, markAllBtn);
newMarkAllBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await API.markAllNotificationsRead();
await loadNotifications();
await updateUnreadCount();
API.toast('已全部标记为已读');
} catch (e) {
console.error('标记全部已读失败:', e);
}
});
}
// 绑定删除已读消息按钮(移除旧的事件监听器)
const deleteBtn = document.getElementById('delete-read');
if (deleteBtn) {
const newDeleteBtn = deleteBtn.cloneNode(true);
deleteBtn.parentNode.replaceChild(newDeleteBtn, deleteBtn);
newDeleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm('确定要删除所有已读消息吗?')) {
return;
}
try {
const result = await API.deleteReadNotifications();
await loadNotifications();
await updateUnreadCount();
API.toast(`已删除 ${result.count || 0} 条已读消息`);
} catch (e) {
console.error('删除已读消息失败:', e);
}
});
}
// 点击其他地方关闭面板
document.addEventListener('click', (e) => {
const panel = document.getElementById('notification-panel');
const currentBell = document.getElementById('notification-bell');
if (isOpen && panel && currentBell && !panel.contains(e.target) && !currentBell.contains(e.target)) {
isOpen = false;
panel.style.display = 'none';
}
});
// 初始加载未读数量
await updateUnreadCount();
// 每30秒更新一次未读数量
if (notificationInterval) {
clearInterval(notificationInterval);
}
notificationInterval = setInterval(updateUnreadCount, 30000);
isInitialized = true;
console.log('[Notifications] 初始化完成');
} catch (e) {
console.error('初始化通知系统失败:', e);
}
}
// 清理定时器
function cleanupNotifications() {
if (notificationInterval) {
clearInterval(notificationInterval);
notificationInterval = null;
}
isInitialized = false;
isOpen = false;
console.log('[Notifications] 已清理');
}
// 导出到全局
window.NotificationSystem = {
init: initNotifications,
cleanup: cleanupNotifications,
refresh: updateUnreadCount
};
})();

View File

@ -0,0 +1,38 @@
Router.register('/personnel', async () => {
const data = await API.personnel().catch(() => ({ list: [] }));
const items = (data.list || []).map(p => `<li><span>${p.name || '人员'}</span><span class="badge">${p.role || ''}</span></li>`).join('');
const html = `
<div class="grid cols-2">
<div class="card">
<div style="font-weight:600;margin-bottom:8px">人员录入</div>
<div class="field"><label>姓名</label><input id="person-name" class="input" /></div>
<div class="field"><label>岗位</label><input id="person-role" class="input" /></div>
<div class="actions"><button id="person-submit" class="btn">提交</button></div>
</div>
<div class="card">
<div style="font-weight:600;margin-bottom:8px">人员信息</div>
<ul id="person-list" class="list">${items || '<li>暂无数据</li>'}</ul>
</div>
</div>`;
setTimeout(() => {
const btn = document.getElementById('person-submit');
btn?.addEventListener('click', async () => {
const name = document.getElementById('person-name').value.trim();
const role = document.getElementById('person-role').value.trim();
if (!name) return API.toast('请输入姓名');
btn.disabled = true;
try {
await API.addPersonnel(name, role);
API.toast('已提交');
const data2 = await API.personnel().catch(() => ({ list: [] }));
const listEl = document.getElementById('person-list');
if (listEl) listEl.innerHTML = (data2.list || []).map(p => `<li><span>${p.name || '人员'}</span><span class="badge">${p.role || ''}</span></li>`).join('') || '<li>暂无数据</li>';
} catch(e) {
API.toast('提交失败');
} finally {
btn.disabled = false;
}
});
}, 0);
return html;
});

View File

@ -0,0 +1,5 @@
Router.register('/production', async () => {
const data = await API.production().catch(() => ({ list: [] }));
const items = (data.list || []).map(r => `<li><span>${r.batch || '批次'}</span><span class="badge">${r.duration || '—'}</span></li>`).join('');
return `<div class="card"><div style="font-weight:600;margin-bottom:8px">生产时间记录</div><ul class="list">${items || '<li>暂无数据</li>'}</ul></div>`;
});

View File

@ -0,0 +1,5 @@
Router.register('/qa', async () => {
const data = await API.qa().catch(() => ({ list: [] }));
const items = (data.list || []).map(q => `<li><span>${q.title || '报告'}</span><span class="badge">${q.date || ''}</span></li>`).join('');
return `<div class="card"><div style="font-weight:600;margin-bottom:8px">质检报告</div><ul class="list">${items || '<li>暂无数据</li>'}</ul></div>`;
});

View File

@ -0,0 +1,355 @@
Router.register('/settings', async () => {
const me = await API.me().catch(()=>({}));
const users = (me && me.role === 'superadmin') ? await API.adminUsers().catch(()=>({list:[]})) : {list:[]};
const userList = (users.list||[]).map(u=>`<li><span>${u.username}</span><span class="badge">${u.role}</span></li>`).join('') || '<li>暂无用户</li>';
const html = `<div class="grid cols-2">
<div class="card">
<div style="font-weight:600;margin-bottom:8px">外观设置</div>
<div class="field"><label>主题</label>
<select class="input" id="theme-select">
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</div>
<div style="margin-top:12px">
<button class="btn" id="refresh-page-btn" style="width:100%">🔄 刷新页面</button>
</div>
</div>
<div class="card">
<div style="font-weight:600;margin-bottom:16px">账户设置</div>
<div style="margin-bottom:16px">
<div style="font-weight:500;margin-bottom:8px">当前用户</div>
<div id="user-card" class="badge">${(me && me.username) ? me.username : '未登录'}</div>
</div>
<div style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--border)">
<div style="font-weight:500;margin-bottom:12px">头像设置</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px">
<img id="preview-avatar" src="${(me && me.avatar) ? me.avatar : './assets/user-avatar.svg'}" alt="头像预览" style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:2px solid var(--border)" />
<div style="flex:1">
<div class="field" style="margin-bottom:8px">
<label>选择头像图片</label>
<input type="file" id="avatar-file" accept="image/*" class="input" style="padding:6px" />
</div>
<div style="font-size:12px;color:var(--text-2)">支持 JPGPNGGIF 等图片格式建议尺寸 200x200 像素</div>
</div>
</div>
<div class="actions">
<button class="btn" id="upload-avatar-btn">上传头像</button>
<button class="btn btn-secondary" id="reset-avatar-btn">恢复默认</button>
</div>
</div>
</div>
${(me && me.role === 'superadmin') ? `
<div class="card">
<div style="font-weight:600;margin-bottom:16px">用户管理</div>
<div style="margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)">
<div style="font-weight:500;margin-bottom:12px">添加新用户</div>
<div class="field">
<label>用户名</label>
<input id="new-username" class="input" placeholder="输入用户名" />
</div>
<div class="field">
<label>密码</label>
<input id="new-password" type="password" class="input" placeholder="输入密码" />
</div>
<div class="field">
<label>角色</label>
<select id="new-role" class="input">
<option value="admin">管理员 (admin)</option>
<option value="superadmin">超级管理员 (superadmin)</option>
</select>
</div>
<div class="actions"><button id="add-user-btn" class="btn">添加用户</button></div>
</div>
<div style="margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)">
<div style="font-weight:500;margin-bottom:12px">修改用户密码</div>
<div class="field">
<label>选择用户</label>
<select id="reset-user" class="input">${(users.list||[]).map(u=>`<option value="${u.username}">${u.username}</option>`).join('')}</select>
</div>
<div class="field">
<label>新密码</label>
<input id="reset-pass" type="password" class="input" />
</div>
<div class="actions"><button id="change-btn" class="btn">修改密码</button></div>
</div>
<div style="font-weight:500;margin-bottom:12px">超级管理员工具</div>
<div style="margin-top:12px"></div>
<div class="grid cols-2" style="margin-top:8px">
<div class="card">
<div style="font-weight:600;margin-bottom:8px">清空上传数据</div>
<div class="actions" style="flex-wrap:wrap;gap:6px">
<button class="btn" data-clear="mac">清空MAC与批次</button>
<button class="btn" data-clear="stats">清空良/不良统计</button>
<button class="btn" data-clear="defects">清空不良明细</button>
<button class="btn" data-clear="shipments">清空发货记录</button>
</div>
</div>
<div class="card">
<div style="font-weight:600;margin-bottom:8px">清空扩展采集</div>
<div class="actions" style="flex-wrap:wrap;gap:6px">
<button class="btn" data-clear="devices">清空设备状态</button>
<button class="btn" data-clear="environment">清空环境参数</button>
<button class="btn" data-clear="personnel">清空人员信息</button>
<button class="btn" data-clear="qa">清空质检报告</button>
<button class="btn" data-clear="production">清空生产时间</button>
</div>
</div>
</div>
<div style="margin-top:12px"></div>
<div class="grid cols-2" style="margin-top:8px">
<div class="card">
<div style="font-weight:600;margin-bottom:8px">数据概览</div>
<div class="grid cols-2">
<div>
<div class="badge" id="overview-stats">/不良统计加载中</div>
<div class="badge" id="overview-defects">不良明细加载中</div>
<div class="badge" id="overview-mac">MAC与批次加载中</div>
</div>
<div>
<div class="badge" id="overview-shipments">发货记录加载中</div>
<div class="badge" id="overview-devices">设备状态加载中</div>
<div class="badge" id="overview-personnel">人员信息加载中</div>
<div class="badge" id="overview-qa">质检报告加载中</div>
<div class="badge" id="overview-production">生产时间加载中</div>
</div>
</div>
</div>
</div>
<div style="margin-top:12px"><div style="font-weight:600;margin-bottom:8px">用户列表</div><ul class="list">${userList}</ul></div>
</div>
` : ''}
</div>`;
setTimeout(() => {
// 主题切换
const themeSelect = document.getElementById('theme-select');
const savedTheme = localStorage.getItem('theme') || 'dark';
if(themeSelect) {
themeSelect.value = savedTheme;
themeSelect.addEventListener('change', (e) => {
const theme = e.target.value;
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
API.toast(`已切换到${theme === 'dark' ? '深色' : '浅色'}主题`);
// 触发自定义事件,通知其他组件主题已更改
window.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme } }));
});
}
// 刷新页面按钮
const refreshBtn = document.getElementById('refresh-page-btn');
refreshBtn?.addEventListener('click', () => {
location.reload();
});
// 头像文件选择预览
const avatarFileInput = document.getElementById('avatar-file');
const previewAvatar = document.getElementById('preview-avatar');
avatarFileInput?.addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (file) {
// 验证文件类型
if (!file.type.startsWith('image/')) {
API.toast('请选择图片文件');
avatarFileInput.value = '';
return;
}
// 验证文件大小限制2MB
if (file.size > 2 * 1024 * 1024) {
API.toast('图片大小不能超过2MB');
avatarFileInput.value = '';
return;
}
// 预览图片
const reader = new FileReader();
reader.onload = (e) => {
previewAvatar.src = e.target.result;
};
reader.readAsDataURL(file);
}
});
// 上传头像
const uploadAvatarBtn = document.getElementById('upload-avatar-btn');
uploadAvatarBtn?.addEventListener('click', async () => {
const file = avatarFileInput?.files?.[0];
if (!file) {
return API.toast('请先选择头像图片');
}
uploadAvatarBtn.disabled = true;
try {
const formData = new FormData();
formData.append('avatar', file);
const res = await fetch('/api/user/upload-avatar', {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('头像上传成功');
// 更新顶部用户头像显示
const avatarImg = document.getElementById('user-avatar-img');
if (avatarImg && data.avatar_url) {
avatarImg.src = data.avatar_url;
}
// 如果有全局更新函数,调用它
if (window.updateUserDisplay) {
const updatedUser = await API.me().catch(() => null);
window.updateUserDisplay(updatedUser);
}
// 清空文件选择
avatarFileInput.value = '';
} else {
API.toast(data.error || '上传失败');
}
} catch(e) {
API.toast('上传失败:' + e.message);
} finally {
uploadAvatarBtn.disabled = false;
}
});
// 恢复默认头像
const resetAvatarBtn = document.getElementById('reset-avatar-btn');
resetAvatarBtn?.addEventListener('click', async () => {
resetAvatarBtn.disabled = true;
try {
const res = await fetch('/api/user/reset-avatar', {
method: 'POST',
credentials: 'include'
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('已恢复默认头像');
previewAvatar.src = './assets/user-avatar.svg';
const avatarImg = document.getElementById('user-avatar-img');
if (avatarImg) {
avatarImg.src = './assets/user-avatar.svg';
}
if (avatarFileInput) avatarFileInput.value = '';
// 如果有全局更新函数,调用它
if (window.updateUserDisplay) {
const updatedUser = await API.me().catch(() => null);
window.updateUserDisplay(updatedUser);
}
} else {
API.toast(data.error || '操作失败');
}
} catch(e) {
API.toast('操作失败:' + e.message);
} finally {
resetAvatarBtn.disabled = false;
}
});
// 添加新用户
const addUserBtn = document.getElementById('add-user-btn');
addUserBtn?.addEventListener('click', async () => {
const usernameEl = document.getElementById('new-username');
const passwordEl = document.getElementById('new-password');
const roleEl = document.getElementById('new-role');
const username = usernameEl?.value?.trim();
const password = passwordEl?.value;
const role = roleEl?.value || 'admin';
if (!username || !password) {
return API.toast('请输入用户名和密码');
}
if (password.length < 6) {
return API.toast('密码长度至少6位');
}
addUserBtn.disabled = true;
try {
const res = await fetch('/api/admin/add-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password, role })
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast(data.message || '用户创建成功');
// 清空输入框
if (usernameEl) usernameEl.value = '';
if (passwordEl) passwordEl.value = '';
// 刷新页面以更新用户列表
setTimeout(() => Router.navigate('/settings'), 1000);
} else {
API.toast(data.error || '创建失败');
}
} catch(e) {
API.toast('创建失败:' + e.message);
} finally {
addUserBtn.disabled = false;
}
});
// 修改密码
const change = document.getElementById('change-btn');
change?.addEventListener('click', async () => {
const uEl = document.getElementById('reset-user');
const pEl = document.getElementById('reset-pass');
const u = uEl ? uEl.value : '';
const p = pEl ? pEl.value : '';
if (!u || !p) return API.toast('请输入用户与新密码');
change.disabled = true;
try {
await API.changePassword(u, p);
API.toast('已修改');
} catch(e) {
API.toast('修改失败');
} finally {
change.disabled = false;
}
});
document.querySelectorAll('button[data-clear]')?.forEach(btn => {
btn.addEventListener('click', async () => {
const mod = btn.getAttribute('data-clear');
btn.disabled = true;
try{
await API.clearModule(mod);
API.toast('已清空');
}catch(e){
API.toast('清空失败');
}finally{
btn.disabled = false;
}
});
});
(async ()=>{
const ov = await API.overview().catch(()=>null);
const setText=(id, text)=>{const el=document.getElementById(id); if(el) el.textContent=text;}
if (ov) {
setText('overview-stats', `良/不良统计:${ov.stats.records} 条(良 ${ov.stats.goodTotal} / 不良 ${ov.stats.badTotal}`);
setText('overview-defects', `不良明细:${ov.defects}`);
setText('overview-mac', `MAC与批次${ov.mac}`);
setText('overview-shipments', `发货记录:${ov.shipments.records} 条(总量 ${ov.shipments.qtyTotal}`);
setText('overview-devices', `设备状态:${ov.devices}`);
setText('overview-personnel', `人员信息:${ov.personnel}`);
setText('overview-qa', `质检报告:${ov.qa}`);
setText('overview-production', `生产时间:${ov.production}`);
}
})();
}, 0);
return html;
});

View File

@ -0,0 +1,158 @@
Router.register('/shipments/query', async () => {
// 获取当前用户信息
let userRole = null;
try {
const userRes = await fetch('/api/auth/me', { credentials: 'include' });
const userData = await userRes.json();
userRole = userData.role;
} catch (e) {
console.error('Failed to get user info:', e);
}
setTimeout(async () => {
const queryBtn = document.getElementById('sn-query-btn');
const snInput = document.getElementById('sn-input');
const resultDiv = document.getElementById('query-result');
const statsDiv = document.getElementById('redis-stats');
const clearBtn = document.getElementById('clear-redis-btn');
// 加载统计信息
const loadStats = async () => {
try {
const res = await fetch('/api/shipments/redis-stats', {
credentials: 'include'
});
const data = await res.json();
if (data.count !== undefined) {
statsDiv.innerHTML = `
<div style="display:flex;align-items:center;gap:12px">
<span style="color:var(--text-2)">数据库出货数量:</span>
<span style="font-weight:600;font-size:18px;color:var(--primary)">${data.count}</span>
</div>
`;
}
} catch (e) {
statsDiv.innerHTML = '<div style="color:var(--text-2)">无法获取统计信息</div>';
}
};
// 初始加载统计
await loadStats();
// 根据用户角色控制清空按钮的显示
if (clearBtn) {
if (userRole !== 'superadmin') {
clearBtn.style.display = 'none';
}
}
const performQuery = async () => {
const sn = snInput?.value?.trim();
if (!sn) {
resultDiv.innerHTML = '<div class="error">请输入 SN/MAC 号</div>';
return;
}
try {
resultDiv.innerHTML = '<div>查询中...</div>';
const res = await fetch(`/api/shipments/query-by-sn?sn=${encodeURIComponent(sn)}`, {
credentials: 'include'
});
const data = await res.json();
if (data.found) {
resultDiv.innerHTML = `
<div class="result-card success">
<div class="result-title"> 找到出货记录</div>
<div class="result-item"><span class="label">SN/MAC:</span> ${data.sn}</div>
<div class="result-item"><span class="label">机种:</span> <span class="badge">${data.platform_name || ''}</span></div>
<div class="result-item"><span class="label">出货日期:</span> ${data.date}</div>
<div class="result-item"><span class="label">箱号:</span> ${data.box}</div>
<div class="result-item"><span class="label">记录时间:</span> ${data.ts}</div>
</div>
`;
} else {
resultDiv.innerHTML = `
<div class="result-card error">
<div class="result-title"> 未找到记录</div>
<div class="result-item">SN/MAC: ${data.sn}</div>
<div class="result-item">${data.message || '该 SN 没有出货记录'}</div>
</div>
`;
}
} catch (e) {
resultDiv.innerHTML = `<div class="error">查询失败:${e.message}</div>`;
}
};
const clearRedis = async () => {
if (!confirm('确定要清空所有发货记录吗?此操作不可恢复!')) {
return;
}
try {
const res = await fetch('/api/shipments/clear-redis', {
method: 'POST',
credentials: 'include'
});
const data = await res.json();
if (data.ok) {
alert(data.message || '清空成功');
await loadStats();
resultDiv.innerHTML = '';
} else {
alert('清空失败:' + (data.error || '未知错误'));
}
} catch (e) {
alert('清空失败:' + e.message);
}
};
queryBtn?.addEventListener('click', performQuery);
// 只有超级管理员才绑定清空按钮事件
if (clearBtn && userRole === 'superadmin') {
clearBtn.addEventListener('click', clearRedis);
}
snInput?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
performQuery();
}
});
}, 0);
// 根据用户角色决定是否显示清空按钮
const showClearButton = userRole === 'superadmin';
return `<div class="card">
<div style="font-weight:600;margin-bottom:16px">SN/MAC 出货查询</div>
<div style="margin-bottom:20px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px">
<div id="redis-stats" style="display:flex;align-items:center;justify-content:space-between">
<div>加载中...</div>
</div>
</div>
<div style="margin-bottom:16px">
<div class="field">
<label>输入 SN/MAC </label>
<input
id="sn-input"
class="input"
placeholder="输入 SN 或 MAC 地址"
style="font-family: monospace"
/>
</div>
<div style="display:flex;gap:8px">
<button class="btn" id="sn-query-btn">查询</button>
${showClearButton ? '<button class="btn" id="clear-redis-btn" style="background:#dc2626">清空所有记录</button>' : ''}
</div>
</div>
<div id="query-result"></div>
</div>`;
});

View File

@ -0,0 +1,250 @@
Router.register('/upload/shipments', async () => {
setTimeout(() => {
const manualStatus = document.getElementById('ship-manual-status');
// 设置默认日期为今天
const dateInput = document.getElementById('ship-date');
if (dateInput && !dateInput.value) {
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
}
// 手动录入提交
const btn = document.getElementById('ship-upload');
btn?.addEventListener('click', async () => {
const date = document.getElementById('ship-date').value;
const qty = parseInt(document.getElementById('ship-qty').value || '0', 10);
const to = document.getElementById('ship-to').value;
const platform = document.getElementById('ship-manual-platform').value;
const boxNo = document.getElementById('ship-box-no').value.trim();
// 验证必填字段
if (!date) {
manualStatus.textContent = '✗ 请选择发货日期';
manualStatus.className = 'error';
return;
}
if (!platform) {
manualStatus.textContent = '✗ 请选择机种类型';
manualStatus.className = 'error';
return;
}
if (!to) {
manualStatus.textContent = '✗ 请输入接收方';
manualStatus.className = 'error';
return;
}
if (qty <= 0) {
manualStatus.textContent = '✗ 数量必须大于0';
manualStatus.className = 'error';
return;
}
try {
manualStatus.textContent = '提交中...';
manualStatus.className = '';
const payload = { date, qty, to, platform };
if (boxNo) {
payload.box_no = boxNo;
}
await API.uploadShipments(payload);
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜'}[platform] || platform;
manualStatus.textContent = `✓ 录入成功!机种:${platformName},数量:${qty}`;
manualStatus.className = 'success';
// 清空表单(保留日期和机种)
document.getElementById('ship-qty').value = '';
document.getElementById('ship-to').value = '';
document.getElementById('ship-box-no').value = '';
} catch(e) {
manualStatus.textContent = '✗ 录入失败:' + (e.message || '未知错误');
manualStatus.className = 'error';
}
});
const fileInput = document.getElementById('ship-file');
const validateBtn = document.getElementById('ship-validate');
const uploadFileBtn = document.getElementById('ship-upload-file');
const fileStatus = document.getElementById('ship-file-status');
fileInput?.addEventListener('change', () => {
fileStatus.textContent = '';
fileStatus.className = '';
});
validateBtn?.addEventListener('click', async () => {
const file = fileInput?.files?.[0];
if (!file) {
fileStatus.textContent = '请先选择文件';
fileStatus.className = 'error';
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/validate/shipments-file', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
const data = await res.json();
if (data.valid) {
fileStatus.textContent = '✓ ' + data.message;
fileStatus.className = 'success';
} else {
fileStatus.textContent = '✗ ' + data.message;
fileStatus.className = 'error';
}
} catch (e) {
fileStatus.textContent = '验证失败:' + e.message;
fileStatus.className = 'error';
}
});
uploadFileBtn?.addEventListener('click', async () => {
const file = fileInput?.files?.[0];
const platform = document.getElementById('ship-platform')?.value;
if (!platform) {
fileStatus.textContent = '✗ 请选择机种类型';
fileStatus.className = 'error';
return;
}
if (!file) {
fileStatus.textContent = '✗ 请先选择文件';
fileStatus.className = 'error';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('platform', platform);
try {
fileStatus.textContent = '上传中...';
fileStatus.className = '';
const res = await fetch('/api/upload/shipments-file', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
const data = await res.json();
if (data.ok) {
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜'}[platform] || platform;
fileStatus.textContent = `✓ 上传成功!机种:${platformName},共导入${data.count}个箱次,${data.total_qty}个SN`;
fileStatus.className = 'success';
fileInput.value = '';
document.getElementById('ship-platform').value = '';
} else {
fileStatus.textContent = '✗ ' + (data.error || '上传失败');
fileStatus.className = 'error';
}
} catch (e) {
fileStatus.textContent = '上传失败:' + e.message;
fileStatus.className = 'error';
}
});
}, 0);
return `<div class="card">
<div style="font-weight:600;margin-bottom:16px">发货记录</div>
<div style="margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)">
<div style="font-weight:500;margin-bottom:12px">手动录入</div>
<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
用于快速录入发货汇总信息不含详细SN
</div>
<div class="row">
<div class="col">
<div class="field">
<label>发货日期 <span style="color:var(--danger)">*</span></label>
<input id="ship-date" type="date" class="input" />
</div>
</div>
<div class="col">
<div class="field">
<label>机种类型 <span style="color:var(--danger)">*</span></label>
<select id="ship-manual-platform" class="input">
<option value="">请选择机种</option>
<option value="pdd">拼多多</option>
<option value="yt">圆通</option>
<option value="tx">兔喜</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="field">
<label>接收方 <span style="color:var(--danger)">*</span></label>
<input id="ship-to" class="input" placeholder="客户名称" />
</div>
</div>
<div class="col">
<div class="field">
<label>数量 <span style="color:var(--danger)">*</span></label>
<input id="ship-qty" type="number" min="1" class="input" placeholder="发货数量" />
</div>
</div>
</div>
<div class="field">
<label>箱号可选</label>
<input id="ship-box-no" class="input" placeholder="例如BOX001" />
</div>
<div id="ship-manual-status" style="margin:8px 0;font-size:13px"></div>
<div class="actions"><button class="btn" id="ship-upload">提交录入</button></div>
</div>
<div>
<div style="font-weight:500;margin-bottom:12px">详细记录批量导入</div>
<div class="format-requirements">
<div style="font-weight:500;margin-bottom:4px">文件格式要求</div>
<div> 必需列出货日期箱号SN1SN2...SN20</div>
<div> 出货日期列支持合并单元格</div>
<div> 支持格式Excel (.xlsx, .xls) CSV</div>
</div>
<div class="field">
<label>机种类型 <span style="color:var(--danger)">*</span></label>
<select id="ship-platform" class="input">
<option value="">请选择机种</option>
<option value="pdd">拼多多</option>
<option value="yt">圆通</option>
<option value="tx">兔喜</option>
</select>
</div>
<div class="field">
<label>选择文件</label>
<input type="file" id="ship-file" accept=".xlsx,.xls,.csv" class="input" style="padding:6px" />
</div>
<div id="ship-file-status" style="margin:8px 0;font-size:13px"></div>
<div class="actions">
<button class="btn" id="ship-validate" style="background:#6c757d">验证文件</button>
<button class="btn" id="ship-upload-file">导入数据</button>
</div>
</div>
</div>`;
});

View File

@ -0,0 +1,11 @@
(() => {
document.querySelectorAll('.nav-item.has-children').forEach(group => {
const btn = group.querySelector('.nav-item-btn');
const caret = btn.querySelector('.caret');
const children = group.querySelector('.nav-children');
btn.addEventListener('click', () => {
children.classList.toggle('open');
caret.classList.toggle('rotate');
});
});
})();

View File

@ -0,0 +1,563 @@
const Upload = (() => {
// 使用localStorage保存上传记录
const STORAGE_KEY = 'mac_upload_history';
// 事件监听器清理
const eventListeners = [];
const addListener = (element, event, handler) => {
if(element){
element.addEventListener(event, handler);
eventListeners.push({element, event, handler});
}
};
const cleanupListeners = () => {
eventListeners.forEach(({element, event, handler}) => {
element.removeEventListener(event, handler);
});
eventListeners.length = 0;
};
Router.onBeforeEach((path) => {
if(!path.startsWith('/upload')){
cleanupListeners();
}
});
function getHistory() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch {
return [];
}
}
function saveToHistory(records) {
try {
const history = getHistory();
const newRecords = records.map(r => ({
...r,
timestamp: new Date().toISOString()
}));
const updated = [...newRecords, ...history].slice(0, 100); // 保留最近100条
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch (e) {
console.error('保存历史记录失败:', e);
}
}
function clearHistory() {
localStorage.removeItem(STORAGE_KEY);
}
function section(title, inner) {
return `<div class="card"><div style="font-weight:600;margin-bottom:8px">${title}</div>${inner}</div>`;
}
function filePicker(id,label,accept){
return `<div class="field"><label>${label}</label><input id="${id}" type="file" class="input" accept="${accept}" /></div>`;
}
function numberInput(id,label){return `<div class="field"><label>${label}</label><input id="${id}" type="number" class="input" /></div>`}
function textarea(id,label,placeholder=''){return `<div class="field"><label>${label}</label><textarea id="${id}" class="input" rows="4" placeholder="${placeholder}"></textarea></div>`}
async function renderMac(){
return section('MAC与批次',`
<div class="field">
<label>上传类型</label>
<select id="mac-type" class="input">
<option value="pdd">拼多多</option>
<option value="yt">圆通</option>
<option value="tx">兔喜</option>
</select>
</div>
${filePicker('mac-file','批量导入(Excel)','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel')}
<div class="actions"><button class="btn" id="mac-upload">上传</button></div>
<div id="upload-log" style="margin-top:12px;display:none">
<div style="font-weight:600;margin-bottom:8px">上传日志</div>
<pre style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:300px;overflow-y:auto;font-size:12px;color:var(--text);white-space:pre-wrap"></pre>
</div>
<div style="margin-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-weight:600">最新记录</span>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="mac-show-history" style="font-size:12px;padding:4px 8px">查看历史</button>
<button class="btn btn-secondary" id="mac-clear-display" style="font-size:12px;padding:4px 8px">清空显示</button>
</div>
</div>
<ul id="mac-list" class="list" style="max-height:300px;overflow-y:auto"></ul>
</div>
`);
}
async function renderStats(){
return section('良/不良统计',`
<div style="background:rgba(79,140,255,0.08);border:1px solid rgba(79,140,255,0.2);border-radius:8px;padding:12px;margin-bottom:12px;font-size:13px;line-height:1.6">
<div style="font-weight:600;margin-bottom:6px;color:var(--primary)">📊 数据说明</div>
<div style="color:var(--text)">
<div><strong>直通良品数</strong></div>
<div><strong>良品数</strong> + </div>
<div><strong>不良品数</strong></div>
<div style="margin-top:6px;padding-top:6px;border-top:1px solid rgba(79,140,255,0.2);color:var(--text-2)">
💡 <strong>计算公式</strong><br>
直通良品率 = 直通良品数 / (良品数 + 不良品数) × 100%<br>
总良品率 = 良品数 / (良品数 + 不良品数) × 100%
</div>
</div>
</div>
<div class="field">
<label>平台类型</label>
<select id="stats-platform" class="input">
<option value="pdd">拼多多</option>
<option value="yt">圆通</option>
<option value="tx">兔喜</option>
</select>
</div>
${numberInput('fpy-good-count','直通良品数量(一次检测通过)')}
${numberInput('good-count','良品数量(最终通过检测的总数)')}
${numberInput('bad-count','不良品数量(最终未通过)')}
<div class="field">
<label>不良明细可选</label>
<textarea id="bad-details" class="input" rows="4" placeholder="每行一个不良记录格式MAC地址,批次号&#10;例如90:A9:F7:DD:EE:FF,D20250000000001"></textarea>
</div>
<div class="actions"><button class="btn" id="stats-upload">上传</button></div>
<div style="margin-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-weight:600">最新记录</span>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="stats-show-history" style="font-size:12px;padding:4px 8px">查看历史</button>
<button class="btn btn-secondary" id="stats-clear-display" style="font-size:12px;padding:4px 8px">清空显示</button>
</div>
</div>
<ul id="stats-list" class="list" style="max-height:300px;overflow-y:auto"></ul>
</div>
`);
}
async function renderRepairs(){
return section('返修记录',`
${numberInput('repair-qty','返修完成数量')}
${textarea('repair-note','备注(可选)','例如:批次号、问题描述等')}
<div class="actions"><button class="btn" id="repairs-upload">上传</button></div>
<div style="margin-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-weight:600">最新记录</span>
<button class="btn btn-secondary" id="repairs-show-all" style="font-size:12px;padding:4px 8px">查看全部</button>
</div>
<ul id="repairs-list" class="list" style="max-height:300px;overflow-y:auto"></ul>
</div>
`);
}
async function renderDefects(){
return section('不良明细',`
${filePicker('defects-file','批量导入不良MAC与批次(Excel/CSV)','text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel')}
${textarea('defects-manual','手动输入','AA:BB:...,BATCH-xyz; ...')}
<div class="actions"><button class="btn" id="defects-upload">上传</button></div>
<div style="margin-top:12px"><div style="font-weight:600;margin-bottom:8px">最新记录</div><ul id="defects-list" class="list"></ul></div>
`);
}
Router.register('/upload/mac', async () => {
const html = await renderMac();
setTimeout(bindMacEvents,0);
setTimeout(()=>{
// 显示历史记录中的最新10条
const history = getHistory();
const listEl=document.getElementById('mac-list');
if(listEl && history.length > 0){
listEl.innerHTML = history.slice(0, 10).map(r=>`<li><span>${r.mac}</span><span class="badge">${r.batch}</span></li>`).join('');
} else if(listEl) {
listEl.innerHTML = '<li>暂无数据</li>';
}
},0);
return html;
});
Router.register('/upload/stats', async () => {
const html = await renderStats();
setTimeout(bindStatsEvents,0);
setTimeout(()=>{
// 显示历史记录中的最新10条
const STATS_STORAGE_KEY = 'stats_upload_history';
try {
const history = JSON.parse(localStorage.getItem(STATS_STORAGE_KEY) || '[]');
const listEl=document.getElementById('stats-list');
if(listEl && history.length > 0){
listEl.innerHTML = history.slice(0, 10).map(r=>{
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜'}[r.platform] || '';
const platformText = platformName ? `${platformName} - ` : '';
let html = `<li><span>${platformText}直通良:${r.fpy_good||0} 良:${r.good} 不良:${r.bad}</span>`;
if(r.details && r.details.length > 0){
html += `<span class="badge">${r.details.length}条明细</span>`;
}
html += '</li>';
return html;
}).join('');
} else if(listEl) {
listEl.innerHTML = '<li>暂无数据</li>';
}
} catch(e) {
console.error('加载历史记录失败:', e);
}
},0);
return html;
});
Router.register('/upload/repairs', async () => {
const html = await renderRepairs();
setTimeout(bindRepairsEvents,0);
setTimeout(async ()=>{
const listEl=document.getElementById('repairs-list');
const data=await API.listRepairs().catch(()=>({list:[]}));
listEl.innerHTML=(data.list||[]).slice(0,10).map(r=>{
const ts = new Date(r.ts).toLocaleString('zh-CN');
return `<li><span>数量: ${r.qty}</span><span class="badge">${r.note||'无备注'}</span><span style="font-size:11px;color:var(--text-2)">${ts}</span></li>`;
}).join('')||'<li>暂无数据</li>';
},0);
return html;
});
Router.register('/upload/defects', async () => {
const html = await renderDefects();
setTimeout(bindDefectsEvents,0);
setTimeout(async ()=>{
const listEl=document.getElementById('defects-list');
const data=await API.listDefects().catch(()=>({list:[]}));
listEl.innerHTML=(data.list||[]).slice(0,10).map(r=>`<li><span>${r.mac}</span><span class="badge">${r.batch}</span></li>`).join('')||'<li>暂无数据</li>';
},0);
return html;
});
function readText(file){return new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(r.result);r.onerror=reject;r.readAsText(file)})}
function parseManual(text){
return text.split(/\n+/).map(l=>l.trim()).filter(Boolean).map(l=>{
const [mac,batch]=l.split(',');
return { mac, batch };
});
}
async function bindMacEvents(){
const fileEl=document.getElementById('mac-file');
const btn = document.getElementById('mac-upload');
const showHistoryBtn = document.getElementById('mac-show-history');
const clearDisplayBtn = document.getElementById('mac-clear-display');
// 文件选择后立即验证
addListener(fileEl, 'change', async ()=>{
const file = fileEl.files[0];
if(!file) return;
try{
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/validate/mac-file', {
method: 'POST',
body: formData
});
const result = await res.json();
if(!result.valid){
API.toast(result.message || '文件格式不正确');
fileEl.value = '';
return;
}
API.toast(result.message || '文件验证通过');
}catch(e){
API.toast('文件验证失败');
fileEl.value = '';
}
});
// 查看历史按钮
addListener(showHistoryBtn, 'click', ()=>{
const history = getHistory();
const listEl = document.getElementById('mac-list');
if(listEl){
if(history.length > 0){
listEl.innerHTML = history.map(r=>`<li><span>${r.mac}</span><span class="badge">${r.batch}</span><span style="font-size:11px;color:var(--text-2);margin-left:8px">${new Date(r.timestamp).toLocaleString('zh-CN')}</span></li>`).join('');
API.toast(`显示全部 ${history.length} 条历史记录`);
} else {
listEl.innerHTML = '<li>暂无历史记录</li>';
}
}
});
// 清空显示按钮
addListener(clearDisplayBtn, 'click', ()=>{
const listEl = document.getElementById('mac-list');
if(listEl){
listEl.innerHTML = '<li>已清空显示</li>';
API.toast('已清空显示(历史记录仍保留)');
}
});
addListener(btn, 'click', async ()=>{
const file = fileEl.files[0];
if(!file){
API.toast('请选择文件');
return;
}
const typeEl = document.getElementById('mac-type');
const uploadType = typeEl ? typeEl.value : 'pdd';
const logContainer = document.getElementById('upload-log');
const logPre = logContainer ? logContainer.querySelector('pre') : null;
try{
btn.disabled = true;
if(logContainer) logContainer.style.display = 'block';
if(logPre) logPre.textContent = '正在上传文件...\n';
const formData = new FormData();
formData.append('file', file);
formData.append('type', uploadType);
const res = await fetch('/api/upload/mac-file', {
method: 'POST',
body: formData
});
const result = await res.json();
if(logPre){
logPre.textContent = result.output || '上传完成';
}
if(result.ok){
API.toast('上传成功');
// 解析并显示成功上传的记录
const output = result.output || '';
const jsonMatch = output.match(/=== 成功导入的数据 ===\n([\s\S]*?)\n=== 数据输出结束 ===/);
if(jsonMatch && jsonMatch[1]){
try{
const records = JSON.parse(jsonMatch[1].trim());
if(records.length > 0){
// 保存到历史记录
saveToHistory(records);
// 显示最新记录
const listEl = document.getElementById('mac-list');
if(listEl){
listEl.innerHTML = records.map(r=>`<li><span>${r.mac}</span><span class="badge">${r.batch}</span></li>`).join('');
}
}
}catch(e){
console.error('解析上传记录失败:', e);
}
}
} else {
API.toast(result.error || '上传失败');
}
if(fileEl) fileEl.value = '';
}catch(e){
API.toast('上传失败: ' + e.message);
if(logPre) logPre.textContent += '\n错误: ' + e.message;
} finally {
if (btn) btn.disabled = false;
}
});
}
async function bindStatsEvents(){
const STATS_STORAGE_KEY = 'stats_upload_history';
const getStatsHistory = () => {
try {
return JSON.parse(localStorage.getItem(STATS_STORAGE_KEY) || '[]');
} catch {
return [];
}
};
const saveToStatsHistory = (record) => {
try {
const history = getStatsHistory();
const newRecord = {
...record,
timestamp: new Date().toISOString()
};
const updated = [newRecord, ...history].slice(0, 100);
localStorage.setItem(STATS_STORAGE_KEY, JSON.stringify(updated));
} catch (e) {
console.error('保存历史记录失败:', e);
}
};
const btn = document.getElementById('stats-upload');
const showHistoryBtn = document.getElementById('stats-show-history');
const clearDisplayBtn = document.getElementById('stats-clear-display');
// 查看历史按钮
addListener(showHistoryBtn, 'click', ()=>{
const history = getStatsHistory();
const listEl = document.getElementById('stats-list');
if(listEl){
if(history.length > 0){
listEl.innerHTML = history.map(r=>{
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜'}[r.platform] || '';
const platformText = platformName ? `${platformName} - ` : '';
let html = `<li><span>${platformText}直通良:${r.fpy_good||0} 良:${r.good} 不良:${r.bad}</span>`;
if(r.details && r.details.length > 0){
html += `<span class="badge">${r.details.length}条明细</span>`;
}
html += `<span style="font-size:11px;color:var(--text-2);margin-left:8px">${new Date(r.timestamp).toLocaleString('zh-CN')}</span></li>`;
return html;
}).join('');
API.toast(`显示全部 ${history.length} 条历史记录`);
} else {
listEl.innerHTML = '<li>暂无历史记录</li>';
}
}
});
// 清空显示按钮
addListener(clearDisplayBtn, 'click', ()=>{
const listEl = document.getElementById('stats-list');
if(listEl){
listEl.innerHTML = '<li>已清空显示</li>';
API.toast('已清空显示(历史记录仍保留)');
}
});
addListener(btn, 'click', async ()=>{
const platform = document.getElementById('stats-platform').value;
const fpyGood=parseInt(document.getElementById('fpy-good-count').value||'0',10);
const good=parseInt(document.getElementById('good-count').value||'0',10);
const bad=parseInt(document.getElementById('bad-count').value||'0',10);
const detailsText = document.getElementById('bad-details')?.value.trim() || '';
if(fpyGood<0||good<0||bad<0){return API.toast('数量不能为负数')}
// 解析不良明细
const details = [];
if(detailsText){
const lines = detailsText.split('\n').filter(l => l.trim());
for(const line of lines){
const [mac, batch] = line.split(',').map(s => s.trim());
if(mac && batch){
details.push({mac, batch});
}
}
}
btn.disabled = true;
try{
await API.uploadStats({platform, fpy_good: fpyGood, good, bad, details});
API.toast('上传成功');
// 保存到历史记录
saveToStatsHistory({platform, fpy_good: fpyGood, good, bad, details});
// 显示最新记录
const listEl=document.getElementById('stats-list');
if(listEl){
const platformName = {pdd: '拼多多', yt: '圆通', tx: '兔喜'}[platform] || platform;
let html = `<li><span>${platformName} - 直通良:${fpyGood} 良:${good} 不良:${bad}</span>`;
if(details.length > 0){
html += `<span class="badge">${details.length}条明细</span>`;
}
html += '</li>';
listEl.innerHTML = html;
}
// 清空输入
document.getElementById('fpy-good-count').value = '';
document.getElementById('good-count').value = '';
document.getElementById('bad-count').value = '';
document.getElementById('bad-details').value = '';
}catch(e){
API.toast('上传失败');
} finally {
if (btn) btn.disabled = false;
}
});
}
async function bindRepairsEvents(){
const btn = document.getElementById('repairs-upload');
const showAllBtn = document.getElementById('repairs-show-all');
// 查看全部按钮
addListener(showAllBtn, 'click', async ()=>{
const listEl = document.getElementById('repairs-list');
const data = await API.listRepairs().catch(()=>({list:[]}));
if(listEl){
if(data.list && data.list.length > 0){
listEl.innerHTML = data.list.map(r=>{
const ts = new Date(r.ts).toLocaleString('zh-CN');
return `<li><span>数量: ${r.qty}</span><span class="badge">${r.note||'无备注'}</span><span style="font-size:11px;color:var(--text-2)">${ts}</span></li>`;
}).join('');
API.toast(`显示全部 ${data.list.length} 条记录`);
} else {
listEl.innerHTML = '<li>暂无记录</li>';
}
}
});
addListener(btn, 'click', async ()=>{
const qty = parseInt(document.getElementById('repair-qty').value||'0', 10);
const note = document.getElementById('repair-note')?.value.trim() || '';
if(qty <= 0){
return API.toast('请输入有效的返修数量');
}
btn.disabled = true;
try{
await API.uploadRepairs({qty, note});
API.toast('上传成功');
// 刷新列表
const listEl = document.getElementById('repairs-list');
const data = await API.listRepairs().catch(()=>({list:[]}));
if(listEl){
listEl.innerHTML = (data.list||[]).slice(0,10).map(r=>{
const ts = new Date(r.ts).toLocaleString('zh-CN');
return `<li><span>数量: ${r.qty}</span><span class="badge">${r.note||'无备注'}</span><span style="font-size:11px;color:var(--text-2)">${ts}</span></li>`;
}).join('')||'<li>暂无数据</li>';
}
// 清空输入
document.getElementById('repair-qty').value = '';
document.getElementById('repair-note').value = '';
}catch(e){
API.toast('上传失败');
} finally {
if (btn) btn.disabled = false;
}
});
}
async function bindDefectsEvents(){
const btn = document.getElementById('defects-upload');
addListener(btn, 'click', async ()=>{
try{
const fileEl=document.getElementById('defects-file');
const manual=document.getElementById('defects-manual').value.trim();
let rows=[];
if(fileEl.files[0]){
const text=await readText(fileEl.files[0]);
rows=text.split(/\n+/).map(l=>l.split(','));
rows=rows.map(([mac,batch])=>({mac,batch}));
}else if(manual){
rows=parseManual(manual);
}
if(!rows.length){throw new Error('请提供文件或手动输入')}
btn.disabled = true;
await API.uploadDefects({rows});
API.toast('上传成功');
const listEl=document.getElementById('defects-list');
const data=await API.listDefects().catch(()=>({list:[]}));
if(listEl) listEl.innerHTML=(data.list||[]).slice(0,10).map(r=>`<li><span>${r.mac}</span><span class="badge">${r.batch}</span></li>`).join('')||'<li>暂无数据</li>';
}catch(e){
API.toast('上传失败');
} finally {
if (btn) btn.disabled = false;
}
});
}
})();

100
frontend/js/router.js Normal file
View File

@ -0,0 +1,100 @@
const Router = (() => {
const routes = {};
const beforeEachHooks = [];
const afterEachHooks = [];
function register(path, render) {
routes[path] = render;
}
function onBeforeEach(fn) { beforeEachHooks.push(fn); }
function onAfterEach(fn) { afterEachHooks.push(fn); }
async function navigate(path) {
for (const h of beforeEachHooks) await h(path);
const view = document.getElementById('view');
view.classList.add('fade-enter');
const render = routes[path] || routes['/404'];
const html = await render();
view.innerHTML = html;
requestAnimationFrame(() => {
view.classList.add('fade-enter-active');
view.classList.remove('fade-enter');
setTimeout(() => view.classList.remove('fade-enter-active'), 220);
});
for (const h of afterEachHooks) await h(path);
}
async function init() {
window.addEventListener('hashchange', () => {
const path = location.hash.replace('#', '') || '/dashboard';
navigate(path);
highlightActive(path);
updateBreadcrumb(path);
});
// 检查用户登录状态,决定默认路径
let defaultPath = '/dashboard';
try {
const user = await API.me().catch(() => null);
if (!user || !user.username) {
defaultPath = '/login';
}
} catch(e) {
defaultPath = '/login';
}
const path = location.hash.replace('#', '') || defaultPath;
navigate(path);
highlightActive(path);
updateBreadcrumb(path);
}
function highlightActive(path) {
document.querySelectorAll('.nav-item, .nav-child').forEach(el => el.classList.remove('active'));
const target = document.querySelector(`[data-route="${routeKey(path)}"]`);
if (target) target.classList.add('active');
}
function routeKey(path) {
return path
.replace('/', '')
.replaceAll('/', '-')
|| 'dashboard';
}
function updateBreadcrumb(path) {
const bc = document.getElementById('breadcrumb');
const parts = path.split('/').filter(Boolean);
let acc = '#';
bc.innerHTML = parts.map((p, i) => {
acc += '/' + p;
const last = i === parts.length - 1;
return `<a href="${acc}" style="color:${last ? 'var(--text)' : 'var(--text-2)'};text-decoration:none">${label(p)}</a>`;
}).join(' / ');
}
function label(key) {
const map = {
dashboard: '仪表盘',
login: '登录',
upload: '上传',
mac: 'MAC与批次',
stats: '良/不良统计',
defects: '不良明细',
repairs: '返修记录',
shipments: '发货记录',
query: '查询',
devices: '设备状态',
environment: '环境参数',
personnel: '人员信息',
qa: '质检报告',
production: '时间记录',
export: '导出',
settings: '设置'
};
return map[key] || key;
}
return { register, init, onBeforeEach, onAfterEach };
})();

View File

@ -0,0 +1,82 @@
// 内存监控工具
const MemoryMonitor = (() => {
let monitorInterval = null;
let isMonitoring = false;
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
};
const getMemoryInfo = () => {
if (performance.memory) {
return {
used: performance.memory.usedJSHeapSize,
total: performance.memory.totalJSHeapSize,
limit: performance.memory.jsHeapSizeLimit,
usedFormatted: formatBytes(performance.memory.usedJSHeapSize),
totalFormatted: formatBytes(performance.memory.totalJSHeapSize),
limitFormatted: formatBytes(performance.memory.jsHeapSizeLimit),
percentage: ((performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100).toFixed(2)
};
}
return null;
};
const start = (interval = 5000) => {
if (isMonitoring) return;
isMonitoring = true;
console.log('[内存监控] 开始监控,间隔:', interval, 'ms');
monitorInterval = setInterval(() => {
const info = getMemoryInfo();
if (info) {
console.log(`[内存监控] 使用: ${info.usedFormatted} / ${info.limitFormatted} (${info.percentage}%)`);
// 如果内存使用超过70%,发出警告
if (parseFloat(info.percentage) > 70) {
console.warn('[内存警告] 内存使用率超过70%,建议清理资源');
}
}
}, interval);
};
const stop = () => {
if (monitorInterval) {
clearInterval(monitorInterval);
monitorInterval = null;
isMonitoring = false;
console.log('[内存监控] 已停止');
}
};
const logCurrent = () => {
const info = getMemoryInfo();
if (info) {
console.log('[内存状态]', info);
} else {
console.log('[内存状态] 浏览器不支持 performance.memory API');
}
};
return {
start,
stop,
getMemoryInfo,
logCurrent
};
})();
// 开发环境下自动启动监控
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
// 延迟启动,避免影响页面加载
setTimeout(() => {
MemoryMonitor.start(10000); // 每10秒监控一次
}, 3000);
}
window.MemoryMonitor = MemoryMonitor;

1
manager.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

129
query_shipment_sn.sh Normal file
View File

@ -0,0 +1,129 @@
#!/bin/bash
# 发货记录 SN 查询脚本
REDIS_HOST="${REDIS_HOST:-180.163.74.83}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD}"
REDIS_DB="${REDIS_DB:-0}"
# 颜色定义
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 构建 redis-cli 命令
REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
if [ -n "$REDIS_PASSWORD" ]; then
REDIS_CMD="$REDIS_CMD -a $REDIS_PASSWORD"
fi
REDIS_CMD="$REDIS_CMD -n $REDIS_DB"
# 显示帮助
show_help() {
echo "发货记录 SN 查询工具"
echo ""
echo "用法:"
echo " $0 <命令> [参数]"
echo ""
echo "命令:"
echo " query <SN> 查询单个 SN 的出货信息"
echo " count 查看总记录数"
echo " list [数量] 列出最近的 SN默认 20 条)"
echo " search <模式> 搜索匹配的 SN如: SN00*"
echo " exists <SN> 检查 SN 是否存在"
echo ""
echo "示例:"
echo " $0 query SN0001"
echo " $0 count"
echo " $0 list 50"
echo " $0 search 'SN00*'"
}
# 查询单个 SN
query_sn() {
local sn=$1
if [ -z "$sn" ]; then
echo -e "${RED}错误: 请提供 SN 号${NC}"
exit 1
fi
echo -e "${YELLOW}查询 SN: $sn${NC}"
result=$($REDIS_CMD HGET shipment_sn_mapping "$sn")
if [ -z "$result" ]; then
echo -e "${RED}未找到该 SN 的出货记录${NC}"
else
echo -e "${GREEN}找到记录:${NC}"
echo "$result" | python3 -m json.tool 2>/dev/null || echo "$result"
fi
}
# 统计总数
count_records() {
echo -e "${YELLOW}统计记录总数...${NC}"
count=$($REDIS_CMD HLEN shipment_sn_mapping)
echo -e "${GREEN}总记录数: $count${NC}"
}
# 列出 SN
list_sns() {
local limit=${1:-20}
echo -e "${YELLOW}列出前 $limit 条记录...${NC}"
$REDIS_CMD HSCAN shipment_sn_mapping 0 COUNT "$limit" | tail -n +2
}
# 搜索 SN
search_sns() {
local pattern=$1
if [ -z "$pattern" ]; then
echo -e "${RED}错误: 请提供搜索模式${NC}"
exit 1
fi
echo -e "${YELLOW}搜索模式: $pattern${NC}"
$REDIS_CMD HSCAN shipment_sn_mapping 0 MATCH "$pattern" COUNT 100
}
# 检查存在
check_exists() {
local sn=$1
if [ -z "$sn" ]; then
echo -e "${RED}错误: 请提供 SN 号${NC}"
exit 1
fi
exists=$($REDIS_CMD HEXISTS shipment_sn_mapping "$sn")
if [ "$exists" = "1" ]; then
echo -e "${GREEN}SN $sn 存在${NC}"
else
echo -e "${RED}SN $sn 不存在${NC}"
fi
}
# 主逻辑
case "${1:-help}" in
query)
query_sn "$2"
;;
count)
count_records
;;
list)
list_sns "$2"
;;
search)
search_sns "$2"
;;
exists)
check_exists "$2"
;;
help|--help|-h)
show_help
;;
*)
echo -e "${RED}未知命令: $1${NC}"
show_help
exit 1
;;
esac

2263
server/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
# 发货记录合并单元格处理说明
## 功能特点
系统现在完全支持 Excel 中出货日期列的合并单元格,这是实际业务中常见的格式。
## Excel 文件结构
### 典型格式
```
| 出货日期 | 箱号 | SN1 | SN2 | ... | SN20 |
|------------|--------|--------|--------|-----|--------|
| 2025-11-20 | BOX001 | SN0001 | SN0002 | ... | SN0020 |
| ↓ | BOX002 | SN0021 | SN0022 | ... | SN0040 |
| ↓ | BOX003 | SN0041 | SN0042 | ... | SN0060 |
| 2025-11-21 | BOX004 | SN0061 | SN0062 | ... | SN0080 |
| ↓ | BOX005 | SN0081 | SN0082 | ... | SN0100 |
```
**说明**
- 出货日期列可以合并单元格
- 同一天的多个箱号共用一个日期单元格
- 每行代表一个箱号
- 横向包含该箱的 20 个 SN
## 解析逻辑
### 1. 合并单元格处理
```python
# 记录上一个有效的日期
last_valid_date = None
for row in rows:
# 如果当前行日期为空(合并单元格的后续行)
if current_date:
last_valid_date = current_date
date = current_date
else:
# 使用上一个有效日期
date = last_valid_date
```
### 2. 数据结构
每行解析后的数据:
```python
{
'date': '2025-11-20', # 出货日期
'box': 'BOX001', # 箱号
'sns': ['SN0001', 'SN0002', ...], # SN 列表
'qty': 20 # SN 数量
}
```
### 3. Redis 存储
每个 SN 单独存储:
```
HSET shipment_sn_mapping "SN0001" '{"date":"2025-11-20","box":"BOX001","ts":"..."}'
HSET shipment_sn_mapping "SN0002" '{"date":"2025-11-20","box":"BOX001","ts":"..."}'
...
```
## 创建测试模板
运行以下命令创建带合并单元格的测试模板:
```bash
python create_shipments_template.py
```
这将生成:
- `shipments_template.xlsx` - 带示例数据和合并单元格
- `shipments_template_empty.xlsx` - 空白模板
## 导入流程
1. **准备 Excel 文件**
- 第一列:出货日期(可合并单元格)
- 第二列:箱号
- 第3-22列SN1-SN20
2. **上传文件**
- 访问"发货记录"页面
- 选择 Excel 文件
- 点击"验证文件"
3. **验证通过后导入**
- 点击"导入数据"
- 系统自动解析合并单元格
- 数据存入 SQLite 和 Redis
4. **查询验证**
- 访问"SN出货查询"页面
- 输入任意 SN 号
- 查看出货日期和箱号
## 支持的格式
### Excel (.xlsx, .xls)
- ✅ 支持合并单元格
- ✅ 自动识别空单元格
- ✅ 按 SN 编号排序SN1, SN2, ..., SN20
### CSV (.csv)
- ✅ 支持空值(相当于合并单元格)
- ✅ 自动使用上一行的日期
- ⚠️ CSV 不支持真正的合并单元格,但空值会被正确处理
## 常见问题
### Q: 如果日期列第一行就是空的怎么办?
A: 系统会跳过该行,直到遇到第一个有效日期。
### Q: 如果某个箱子的 SN 不足 20 个?
A: 没问题,后面的 SN 列可以留空,系统只记录有值的 SN。
### Q: 可以一天有多少个箱子?
A: 没有限制,同一天可以有任意多个箱子,都会使用同一个日期。
### Q: 箱号可以重复吗?
A: 技术上可以,但建议保持唯一,便于追溯。
## 数据验证
导入后可以通过以下方式验证:
### 1. 查看 Redis 记录数
```bash
redis-cli HLEN shipment_sn_mapping
```
### 2. 查询特定 SN
```bash
redis-cli HGET shipment_sn_mapping "SN0001"
```
### 3. 通过 Web 界面
- 访问"SN出货查询"页面
- 页面顶部显示总记录数
- 输入 SN 查询详细信息

View File

@ -0,0 +1,100 @@
# 发货记录 Redis 存储结构说明
## Redis 数据结构
### Hash 表shipment_sn_mapping
用于存储 SN/MAC 到出货信息的映射关系。
**数据类型**: Redis Hash
**Key**: `shipment_sn_mapping`
**结构**:
```
Hash Key (Field) -> Hash Value
SN/MAC 号 -> JSON 格式的出货信息
```
### 存储示例
```redis
HSET shipment_sn_mapping "SN0001" '{"date":"2025-11-20","box":"BOX001","ts":"2025-11-20T10:30:00"}'
HSET shipment_sn_mapping "SN0002" '{"date":"2025-11-20","box":"BOX001","ts":"2025-11-20T10:30:00"}'
HSET shipment_sn_mapping "SN0021" '{"date":"2025-11-20","box":"BOX002","ts":"2025-11-20T10:30:00"}'
```
### JSON 值格式
```json
{
"date": "2025-11-20", // 出货日期
"box": "BOX001", // 箱号
"ts": "2025-11-20T10:30:00" // 记录时间戳
}
```
## 查询方式
### 1. 通过 API 查询
**接口**: `GET /api/shipments/query-by-sn?sn=SN0001`
**响应示例**:
```json
{
"found": true,
"sn": "SN0001",
"date": "2025-11-20",
"box": "BOX001",
"ts": "2025-11-20T10:30:00"
}
```
### 2. 直接使用 Redis 命令查询
```bash
# 查询单个 SN
redis-cli HGET shipment_sn_mapping "SN0001"
# 查询多个 SN
redis-cli HMGET shipment_sn_mapping "SN0001" "SN0002" "SN0003"
# 查看所有记录数量
redis-cli HLEN shipment_sn_mapping
# 查看所有 SN谨慎使用数据量大时会很慢
redis-cli HKEYS shipment_sn_mapping
# 扫描部分数据
redis-cli HSCAN shipment_sn_mapping 0 COUNT 100
```
## 数据导入流程
1. 用户上传 Excel 文件包含出货日期、箱号、SN1-SN20
2. 系统解析 Excel 文件
3. 提取每行的出货日期、箱号和所有 SN
4. 将数据保存到 SQLite用于统计和列表展示
5. 同时将每个 SN 映射关系写入 Redis Hash用于快速查询
## 性能特点
- **查询速度**: O(1) 时间复杂度,毫秒级响应
- **存储效率**: Hash 结构比单独的 Key-Value 更节省内存
- **批量写入**: 使用 Pipeline 批量写入,提高导入速度
- **容错性**: Redis 写入失败不影响 SQLite 数据保存
## 使用场景
1. **快速查询**: 扫描 SN 后立即查询出货信息
2. **追溯管理**: 根据 MAC 地址追溯出货批次
3. **质量追踪**: 结合不良品记录,追溯出货批次
4. **客户服务**: 快速响应客户关于产品出货信息的查询
## 维护建议
1. **定期备份**: 使用 Redis RDB 或 AOF 持久化
2. **监控容量**: 定期检查 Hash 大小 `HLEN shipment_sn_mapping`
3. **数据清理**: 根据业务需求定期清理过期数据
4. **索引优化**: 如需按日期或箱号查询,可建立额外的索引结构

View File

@ -0,0 +1,62 @@
# 发货记录 Excel 模板说明
## 文件格式要求
### 必需列(按顺序)
1. **出货日期** - 格式YYYY-MM-DD例如2025-11-20**支持合并单元格**
2. **箱号** - 箱子编号例如BOX001
3. **SN1** - 第1个序列号
4. **SN2** - 第2个序列号
5. **SN3** - 第3个序列号
6. ...
7. **SN20** - 第20个序列号
### 支持的文件格式
- Excel 文件:.xlsx, .xls
- CSV 文件:.csv
## Excel 模板示例
### 格式 1出货日期合并单元格推荐
| 出货日期 | 箱号 | SN1 | SN2 | SN3 | SN4 | SN5 | ... | SN20 |
|---------|------|-----|-----|-----|-----|-----|-----|------|
| 2025-11-20 | BOX001 | SN001 | SN002 | SN003 | SN004 | SN005 | ... | SN020 |
| ↓(合并) | BOX002 | SN021 | SN022 | SN023 | SN024 | SN025 | ... | SN040 |
| ↓(合并) | BOX003 | SN041 | SN042 | SN043 | SN044 | SN045 | ... | SN060 |
| 2025-11-21 | BOX004 | SN061 | SN062 | SN063 | SN064 | SN065 | ... | SN080 |
| ↓(合并) | BOX005 | SN081 | SN082 | SN083 | SN084 | SN085 | ... | SN100 |
**说明**:同一天的多个箱号,出货日期列可以合并单元格,系统会自动识别。
### 格式 2出货日期不合并
| 出货日期 | 箱号 | SN1 | SN2 | SN3 | SN4 | SN5 | ... | SN20 |
|---------|------|-----|-----|-----|-----|-----|-----|------|
| 2025-11-20 | BOX001 | SN001 | SN002 | SN003 | SN004 | SN005 | ... | SN020 |
| 2025-11-20 | BOX002 | SN021 | SN022 | SN023 | SN024 | SN025 | ... | SN040 |
| 2025-11-21 | BOX003 | SN041 | SN042 | SN043 | SN044 | SN045 | ... | SN060 |
## 注意事项
1. **列头必须完全匹配**出货日期、箱号、SN1、SN2、...、SN20
2. **合并单元格支持**:出货日期列支持合并单元格,同一天的多个箱号可以共用一个日期单元格
3. **SN列可以为空**如果某个箱子不足20个SN后面的SN列可以留空
4. **日期格式**:建议使用 YYYY-MM-DD 格式例如2025-11-20
5. **箱号唯一性**:建议每个箱号保持唯一,便于追溯
6. **数据行**:第一行为表头,从第二行开始为数据
7. **横向结构**:一行代表一个箱号,横向包含该箱的所有 SN最多20个
## 使用流程
1. 准备符合格式要求的 Excel 文件
2. 在系统中点击"选择文件"上传文件
3. 点击"验证文件"检查格式是否正确
4. 验证通过后,点击"导入数据"完成上传
5. 系统会自动统计每个箱次的SN数量并保存
## 导入结果
- 系统会显示导入的箱次数量
- 系统会显示导入的总SN数量
- 每条记录会保存出货日期、数量实际SN个数、箱号信息

71
test-theme.html Normal file
View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>主题测试</title>
<link rel="stylesheet" href="frontend/assets/styles.css" />
<style>
body { padding: 20px; }
.test-section { margin: 20px 0; padding: 20px; border: 2px solid var(--border); border-radius: 8px; }
.theme-toggle { margin-bottom: 20px; }
</style>
</head>
<body>
<div class="theme-toggle">
<button class="btn" onclick="toggleTheme()">切换主题</button>
<span id="current-theme"></span>
</div>
<div class="test-section">
<h3>上传日志测试</h3>
<div id="upload-log">
<div style="font-weight:600;margin-bottom:8px">上传日志</div>
<pre style="background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:300px;overflow-y:auto;font-size:12px;color:var(--text);white-space:pre-wrap">
[2024-01-01 10:00:00] 开始上传文件...
[2024-01-01 10:00:01] 验证文件格式...
[2024-01-01 10:00:02] 处理数据中...
[2024-01-01 10:00:03] 成功上传 100 条记录
[2024-01-01 10:00:04] 完成!
</pre>
</div>
</div>
<div class="test-section">
<h3>日期选择器测试</h3>
<input type="date" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none" />
</div>
<div class="test-section">
<h3>卡片测试</h3>
<div class="card">
<div style="font-weight:600;margin-bottom:8px">测试卡片</div>
<p>这是一个测试卡片,用于验证主题颜色是否正确。</p>
<ul class="list">
<li><span>项目1</span><span class="badge">标签</span></li>
<li><span>项目2</span><span class="badge">标签</span></li>
</ul>
</div>
</div>
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const newTheme = current === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeDisplay();
}
function updateThemeDisplay() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
document.getElementById('current-theme').textContent = `当前主题: ${current === 'light' ? '浅色' : '深色'}`;
}
// 初始化
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeDisplay();
</script>
</body>
</html>

59
validate_excel.py Normal file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
验证Excel文件格式是否符合MAC与批次导入要求
"""
import sys
import pandas as pd
import warnings
warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl')
def validate_excel(file_path):
"""
验证Excel文件格式
返回: (is_valid, error_message)
"""
try:
df = pd.read_excel(file_path)
if df.empty:
return False, "文件为空,没有数据"
columns = df.columns.tolist()
# 检查是否有批次号列
if '批次号' not in columns:
return False, "缺少必需的列:批次号"
# 检查是否有MAC或SN_MAC列
has_mac = 'MAC' in columns
has_sn_mac = 'SN_MAC' in columns
if not has_mac and not has_sn_mac:
return False, "缺少必需的列MAC 或 SN_MAC"
# 检查列数应该只有2列
if len(columns) != 2:
return False, f"文件应该只包含2列数据当前有{len(columns)}列:{', '.join(columns)}"
# 验证通过
mac_col = 'MAC' if has_mac else 'SN_MAC'
return True, f"文件格式正确,包含列:{mac_col} 和 批次号,共{len(df)}行数据"
except Exception as e:
return False, f"读取文件失败:{str(e)}"
if __name__ == '__main__':
if len(sys.argv) < 2:
print("用法: python validate_excel.py <excel文件路径>")
sys.exit(1)
file_path = sys.argv[1]
is_valid, message = validate_excel(file_path)
if is_valid:
print(f"{message}")
sys.exit(0)
else:
print(f"{message}")
sys.exit(1)

1
用户.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB