From c9efcd35718977e134a7ec20e540095fea41afc4 Mon Sep 17 00:00:00 2001 From: zzh Date: Fri, 21 Nov 2025 21:27:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E9=9F=AC=E6=99=BA=E7=94=9F=E4=BA=A7=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20=E5=8A=9F=E8=83=BD=E7=89=B9=E6=80=A7=EF=BC=9A=20-?= =?UTF-8?q?=20=E7=94=A8=E6=88=B7=E8=AE=A4=E8=AF=81=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=88=E7=99=BB=E5=BD=95/=E7=99=BB=E5=87=BA/=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AE=A1=E7=90=86=EF=BC=89=20-=20=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA=20-=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=EF=BC=88MAC?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E3=80=81=E8=89=AF/=E4=B8=8D=E8=89=AF?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E3=80=81=E8=BF=94=E4=BF=AE=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E3=80=81=E5=8F=91=E8=B4=A7=E8=AE=B0=E5=BD=95=EF=BC=89=20-=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E6=94=AF=E6=8C=81Excel/CSV=E6=96=87=E4=BB=B6=EF=BC=89=20-=20?= =?UTF-8?q?=E5=87=BA=E8=B4=A7=E6=9F=A5=E8=AF=A2=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E6=94=AF=E6=8C=81SN=E6=9F=A5=E8=AF=A2=EF=BC=89=20-=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E9=87=87=E9=9B=86=EF=BC=88=E8=AE=BE=E5=A4=87=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E3=80=81=E7=8E=AF=E5=A2=83=E5=8F=82=E6=95=B0=E3=80=81?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E4=BF=A1=E6=81=AF=E3=80=81=E8=B4=A8=E6=A3=80?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E3=80=81=E7=94=9F=E4=BA=A7=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=89=20-=20=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88Excel/PDF=EF=BC=89=20-=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=88=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=EF=BC=89=20-=20=E7=94=A8=E6=88=B7=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=20-=20=E6=B7=B1=E8=89=B2/?= =?UTF-8?q?=E6=B5=85=E8=89=B2=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=20-=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BC=8F=E8=AE=BE=E8=AE=A1=20=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=A0=88=EF=BC=9A=20-=20=E5=90=8E=E7=AB=AF=EF=BC=9APy?= =?UTF-8?q?thon=20Flask=20+=20SQLite=20-=20=E5=89=8D=E7=AB=AF=EF=BC=9A?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=20JavaScript=20+=20CSS=20-=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=EF=BC=9ARedis=20-=20=E9=83=A8=E7=BD=B2=EF=BC=9ANginx?= =?UTF-8?q?=20+=20Systemd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 + .gitignore | 60 + FPY_FEATURE.md | 116 ++ MEMORY_OPTIMIZATION.md | 160 ++ NOTIFICATION_DEBUG.md | 130 ++ NOTIFICATION_SYSTEM.md | 94 + batch_import.py | 133 ++ check_excel.py | 46 + create_shipments_template.py | 107 + dashboard.svg | 1 + date-dark.svg | 1 + date.svg | 1 + deploy/nginx/prod-mgmt.conf | 12 + deploy/systemd/prod-mgmt.service | 12 + frontend/assets/avatars/.gitignore | 4 + frontend/assets/calendar-dark.svg | 1 + frontend/assets/calendar.svg | 1 + frontend/assets/dashboard.svg | 1 + frontend/assets/favicon.svg | 1 + frontend/assets/styles.css | 535 +++++ frontend/assets/user-avatar.svg | 1 + frontend/index.html | 173 ++ frontend/js/api.js | 132 ++ frontend/js/app.js | 103 + frontend/js/components/dashboard.js | 464 +++++ frontend/js/components/defects.js | 3 + frontend/js/components/devices.js | 8 + frontend/js/components/environment.js | 7 + frontend/js/components/export.js | 472 +++++ frontend/js/components/login.js | 25 + frontend/js/components/notifications.js | 255 +++ frontend/js/components/personnel.js | 38 + frontend/js/components/production.js | 5 + frontend/js/components/qa.js | 5 + frontend/js/components/settings.js | 355 ++++ frontend/js/components/shipment-query.js | 158 ++ frontend/js/components/shipments.js | 250 +++ frontend/js/components/sidebar.js | 11 + frontend/js/components/upload.js | 563 ++++++ frontend/js/router.js | 100 + frontend/js/utils/memory-monitor.js | 82 + manager.svg | 1 + query_shipment_sn.sh | 129 ++ server/app.py | 2263 ++++++++++++++++++++++ shipments_merge_cells_guide.md | 139 ++ shipments_redis_structure.md | 100 + shipments_template_example.md | 62 + test-theme.html | 71 + validate_excel.py | 59 + 用户.svg | 1 + 50 files changed, 7464 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 FPY_FEATURE.md create mode 100644 MEMORY_OPTIMIZATION.md create mode 100644 NOTIFICATION_DEBUG.md create mode 100644 NOTIFICATION_SYSTEM.md create mode 100644 batch_import.py create mode 100644 check_excel.py create mode 100644 create_shipments_template.py create mode 100644 dashboard.svg create mode 100644 date-dark.svg create mode 100644 date.svg create mode 100644 deploy/nginx/prod-mgmt.conf create mode 100644 deploy/systemd/prod-mgmt.service create mode 100644 frontend/assets/avatars/.gitignore create mode 100644 frontend/assets/calendar-dark.svg create mode 100644 frontend/assets/calendar.svg create mode 100644 frontend/assets/dashboard.svg create mode 100644 frontend/assets/favicon.svg create mode 100644 frontend/assets/styles.css create mode 100644 frontend/assets/user-avatar.svg create mode 100644 frontend/index.html create mode 100644 frontend/js/api.js create mode 100644 frontend/js/app.js create mode 100644 frontend/js/components/dashboard.js create mode 100644 frontend/js/components/defects.js create mode 100644 frontend/js/components/devices.js create mode 100644 frontend/js/components/environment.js create mode 100644 frontend/js/components/export.js create mode 100644 frontend/js/components/login.js create mode 100644 frontend/js/components/notifications.js create mode 100644 frontend/js/components/personnel.js create mode 100644 frontend/js/components/production.js create mode 100644 frontend/js/components/qa.js create mode 100644 frontend/js/components/settings.js create mode 100644 frontend/js/components/shipment-query.js create mode 100644 frontend/js/components/shipments.js create mode 100644 frontend/js/components/sidebar.js create mode 100644 frontend/js/components/upload.js create mode 100644 frontend/js/router.js create mode 100644 frontend/js/utils/memory-monitor.js create mode 100644 manager.svg create mode 100644 query_shipment_sn.sh create mode 100644 server/app.py create mode 100644 shipments_merge_cells_guide.md create mode 100644 shipments_redis_structure.md create mode 100644 shipments_template_example.md create mode 100644 test-theme.html create mode 100644 validate_excel.py create mode 100644 用户.svg diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..731d04b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0d6a7c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/FPY_FEATURE.md b/FPY_FEATURE.md new file mode 100644 index 0000000..a6b92e5 --- /dev/null +++ b/FPY_FEATURE.md @@ -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%左右),说明返修环节有效 diff --git a/MEMORY_OPTIMIZATION.md b/MEMORY_OPTIMIZATION.md new file mode 100644 index 0000000..1865bd8 --- /dev/null +++ b/MEMORY_OPTIMIZATION.md @@ -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); // 改为120000(2分钟) +``` + +## 测试建议 + +1. 打开浏览器控制台 +2. 进入 Dashboard 页面 +3. 观察内存监控日志 +4. 等待约1分钟查看清理日志 +5. 切换到其他页面,确认定时器已停止 diff --git a/NOTIFICATION_DEBUG.md b/NOTIFICATION_DEBUG.md new file mode 100644 index 0000000..a548526 --- /dev/null +++ b/NOTIFICATION_DEBUG.md @@ -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层级,确保在所有页面都能正常显示 diff --git a/NOTIFICATION_SYSTEM.md b/NOTIFICATION_SYSTEM.md new file mode 100644 index 0000000..c115bcd --- /dev/null +++ b/NOTIFICATION_SYSTEM.md @@ -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. **新增删除已读功能**:可以清理已读消息,保持消息列表整洁 diff --git a/batch_import.py b/batch_import.py new file mode 100644 index 0000000..72b6a46 --- /dev/null +++ b/batch_import.py @@ -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}") diff --git a/check_excel.py b/check_excel.py new file mode 100644 index 0000000..6b110a4 --- /dev/null +++ b/check_excel.py @@ -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}") diff --git a/create_shipments_template.py b/create_shipments_template.py new file mode 100644 index 0000000..d152530 --- /dev/null +++ b/create_shipments_template.py @@ -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("完成!您可以使用这些模板文件进行测试。") diff --git a/dashboard.svg b/dashboard.svg new file mode 100644 index 0000000..25e6bfa --- /dev/null +++ b/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/date-dark.svg b/date-dark.svg new file mode 100644 index 0000000..33bfae1 --- /dev/null +++ b/date-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/date.svg b/date.svg new file mode 100644 index 0000000..cc636f7 --- /dev/null +++ b/date.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/deploy/nginx/prod-mgmt.conf b/deploy/nginx/prod-mgmt.conf new file mode 100644 index 0000000..67385ed --- /dev/null +++ b/deploy/nginx/prod-mgmt.conf @@ -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; + } +} \ No newline at end of file diff --git a/deploy/systemd/prod-mgmt.service b/deploy/systemd/prod-mgmt.service new file mode 100644 index 0000000..fd73e4c --- /dev/null +++ b/deploy/systemd/prod-mgmt.service @@ -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 \ No newline at end of file diff --git a/frontend/assets/avatars/.gitignore b/frontend/assets/avatars/.gitignore new file mode 100644 index 0000000..4a3e539 --- /dev/null +++ b/frontend/assets/avatars/.gitignore @@ -0,0 +1,4 @@ +# 忽略所有上传的头像文件 +* +# 但保留此目录 +!.gitignore diff --git a/frontend/assets/calendar-dark.svg b/frontend/assets/calendar-dark.svg new file mode 100644 index 0000000..33bfae1 --- /dev/null +++ b/frontend/assets/calendar-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/calendar.svg b/frontend/assets/calendar.svg new file mode 100644 index 0000000..cc636f7 --- /dev/null +++ b/frontend/assets/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/dashboard.svg b/frontend/assets/dashboard.svg new file mode 100644 index 0000000..25e6bfa --- /dev/null +++ b/frontend/assets/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/favicon.svg b/frontend/assets/favicon.svg new file mode 100644 index 0000000..a7fc8fc --- /dev/null +++ b/frontend/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css new file mode 100644 index 0000000..b533644 --- /dev/null +++ b/frontend/assets/styles.css @@ -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; +} diff --git a/frontend/assets/user-avatar.svg b/frontend/assets/user-avatar.svg new file mode 100644 index 0000000..6834ce2 --- /dev/null +++ b/frontend/assets/user-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..372af0e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,173 @@ + + + + + +韬智生产管理系统 + + + + + + + +
+ +
+
+ +
+ +
+ + +
+ +
+
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..31e5c8d --- /dev/null +++ b/frontend/js/api.js @@ -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) : ''; +} \ No newline at end of file diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..7441894 --- /dev/null +++ b/frontend/js/app.js @@ -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; +})(); \ No newline at end of file diff --git a/frontend/js/components/dashboard.js b/frontend/js/components/dashboard.js new file mode 100644 index 0000000..f311553 --- /dev/null +++ b/frontend/js/components/dashboard.js @@ -0,0 +1,464 @@ +const Dashboard = (() => { + function metricsCard(title, value, badgeClass) { + return `
+
+
${title}
+
${value}
+
+
`; + } + + // 清理函数 + 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=>`
  • ${r.ts_cn||'—'}${r.batch||''}${r.mac||''}${r.note||''}
  • `).join('')||'
  • 暂无数据
  • '; + const ytList = (yt.list||[]).map(r=>`
  • ${r.ts_cn||'—'}${r.batch||''}${r.mac||''}${r.note||''}
  • `).join('')||'
  • 暂无数据
  • '; + 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 = `
    ${date}
    拼多多: ${pddCount}
    圆通: ${ytCount}
    `; + 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=>`
  • ${r.ts_cn||'—'}${r.batch||''}${r.mac||''}${r.note||''}
  • `).join('')||'
  • 暂无数据
  • '; + const ytEls=ytView.map(r=>`
  • ${r.ts_cn||'—'}${r.batch||''}${r.mac||''}${r.note||''}
  • `).join('')||'
  • 暂无数据
  • '; + 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=>`
  • ${r.ts_cn||'—'}${r.batch||''}${r.mac||''}${r.note||''}
  • `).join('')||'
  • 暂无数据
  • '; + const ytEls=ytView.slice(0, 100).map(r=>`
  • ${r.ts_cn||'—'}${r.batch||''}${r.mac||''}${r.note||''}
  • `).join('')||'
  • 暂无数据
  • '; + 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 ` +
    +
    + ${metricsCard('直通良品率', data.fpyRate || '—', 'success')} + ${metricsCard('良品率', data.goodRate, 'success')} + ${metricsCard('发货数量', data.shipments, 'warning')} + ${metricsCard('不良数量', data.badCount ?? data.defects, 'danger')} +
    +
    +
    审计趋势(最近30天)
    +
    + + +
    +
    +
    +
    +
    + 拼多多审计 + +
    +
      ${pddList}
    +
    +
    +
    + 圆通审计 + +
    +
      ${ytList}
    +
    +
    +
    + `; + } + + Router.register('/dashboard', render); +})(); \ No newline at end of file diff --git a/frontend/js/components/defects.js b/frontend/js/components/defects.js new file mode 100644 index 0000000..5e455d2 --- /dev/null +++ b/frontend/js/components/defects.js @@ -0,0 +1,3 @@ +Router.register('/defects', async () => { + return `
    此页面占位
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/devices.js b/frontend/js/components/devices.js new file mode 100644 index 0000000..f556ff9 --- /dev/null +++ b/frontend/js/components/devices.js @@ -0,0 +1,8 @@ +Router.register('/devices', async () => { + const data = await API.devices().catch(() => ({ list: [] })); + const items = (data.list || []).map(d => `
  • ${d.name || '设备'}${d.status||'未知'}
  • `).join(''); + return `
    +
    设备状态
    + +
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/environment.js b/frontend/js/components/environment.js new file mode 100644 index 0000000..582c571 --- /dev/null +++ b/frontend/js/components/environment.js @@ -0,0 +1,7 @@ +Router.register('/environment', async () => { + const data = await API.environment().catch(() => ({ temp: '—', hum: '—' })); + return `
    +
    温度
    ${data.temp}
    +
    湿度
    ${data.hum}
    +
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/export.js b/frontend/js/components/export.js new file mode 100644 index 0000000..6342278 --- /dev/null +++ b/frontend/js/components/export.js @@ -0,0 +1,472 @@ +const Export = (() => { + async function render() { + setTimeout(bindEvents, 0); + return ` +
    +
    📤 数据导出
    + +
    + + +
    + +
    + +
    + + +
    +
    + + +
    + +
    +
    📋 数据预览
    +
    +
    请选择数据类型查看预览
    +
    +
    + `; + } + + 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 = '
    加载中...
    '; + + 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 = '
    加载失败
    '; + } + } + + function renderStatsPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + + + ${list.slice(0, 50).map(item => ` + + + + + + + `).join('')} + +
    直通良品数良品数不良品数时间
    ${item.fpy_good || 0}${item.good}${item.bad}${new Date(item.ts).toLocaleString('zh-CN')}
    + ${list.length > 50 ? `
    仅显示前50条,导出时将包含全部 ${list.length} 条数据
    ` : ''} + `; + } + + function renderMacPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + + ${list.slice(0, 50).map(item => ` + + + + + + `).join('')} + +
    MAC地址批次号时间
    ${item.mac}${item.batch}${new Date(item.ts).toLocaleString('zh-CN')}
    + ${list.length > 50 ? `
    仅显示前50条,导出时将包含全部 ${list.length} 条数据
    ` : ''} + `; + } + + function renderRepairsPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + + ${list.slice(0, 50).map(item => ` + + + + + + `).join('')} + +
    返修数量备注时间
    ${item.qty}${item.note || '无'}${new Date(item.ts).toLocaleString('zh-CN')}
    + ${list.length > 50 ? `
    仅显示前50条,导出时将包含全部 ${list.length} 条数据
    ` : ''} + `; + } + + function renderDefectsPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + + ${list.slice(0, 50).map(item => ` + + + + + + `).join('')} + +
    MAC地址批次号时间
    ${item.mac}${item.batch}${new Date(item.ts).toLocaleString('zh-CN')}
    + ${list.length > 50 ? `
    仅显示前50条,导出时将包含全部 ${list.length} 条数据
    ` : ''} + `; + } + + function renderShipmentsPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + + + ${list.slice(0, 50).map(item => ` + + + + + + + `).join('')} + +
    日期数量收货方时间
    ${item.date}${item.qty}${item.receiver}${new Date(item.ts).toLocaleString('zh-CN')}
    + ${list.length > 50 ? `
    仅显示前50条,导出时将包含全部 ${list.length} 条数据
    ` : ''} + `; + } + + function renderDevicesPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + ${list.map(item => ` + + + + + `).join('')} + +
    设备名称状态
    ${item.name}${item.status}
    + `; + } + + function renderEnvironmentPreview(data) { + const preview = document.getElementById('export-preview'); + preview.innerHTML = ` + + + + + + + + + + + + + + + + + +
    参数
    温度${data.temp || '—'}
    湿度${data.hum || '—'}
    + `; + } + + function renderPersonnelPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + ${list.map(item => ` + + + + + `).join('')} + +
    姓名角色
    ${item.name}${item.role || '—'}
    + `; + } + + function renderQaPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + ${list.map(item => ` + + + + + `).join('')} + +
    标题日期
    ${item.title}${item.date}
    + `; + } + + function renderProductionPreview(list) { + const preview = document.getElementById('export-preview'); + if (list.length === 0) { + preview.innerHTML = '
    暂无数据
    '; + return; + } + preview.innerHTML = ` + + + + + + + + + ${list.map(item => ` + + + + + `).join('')} + +
    批次时长
    ${item.batch}${item.duration}
    + `; + } + + 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); +})(); \ No newline at end of file diff --git a/frontend/js/components/login.js b/frontend/js/components/login.js new file mode 100644 index 0000000..b9090b8 --- /dev/null +++ b/frontend/js/components/login.js @@ -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 `
    +
    +
    管理员登录
    +
    +
    +
    +
    +
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/notifications.js b/frontend/js/components/notifications.js new file mode 100644 index 0000000..39adda4 --- /dev/null +++ b/frontend/js/components/notifications.js @@ -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 = '
    暂无消息通知
    '; + return; + } + + container.innerHTML = list.map(item => ` +
    +
    ${item.username || '用户'}
    +
    ${getActionText(item.action)}
    + ${item.detail ? `
    ${item.detail}
    ` : ''} +
    ${formatTime(item.ts)}
    +
    + `).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 + }; +})(); diff --git a/frontend/js/components/personnel.js b/frontend/js/components/personnel.js new file mode 100644 index 0000000..0277b06 --- /dev/null +++ b/frontend/js/components/personnel.js @@ -0,0 +1,38 @@ +Router.register('/personnel', async () => { + const data = await API.personnel().catch(() => ({ list: [] })); + const items = (data.list || []).map(p => `
  • ${p.name || '人员'}${p.role || ''}
  • `).join(''); + const html = ` +
    +
    +
    人员录入
    +
    +
    +
    +
    +
    +
    人员信息
    +
      ${items || '
    • 暂无数据
    • '}
    +
    +
    `; + 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 => `
  • ${p.name || '人员'}${p.role || ''}
  • `).join('') || '
  • 暂无数据
  • '; + } catch(e) { + API.toast('提交失败'); + } finally { + btn.disabled = false; + } + }); + }, 0); + return html; +}); \ No newline at end of file diff --git a/frontend/js/components/production.js b/frontend/js/components/production.js new file mode 100644 index 0000000..a0b4ca8 --- /dev/null +++ b/frontend/js/components/production.js @@ -0,0 +1,5 @@ +Router.register('/production', async () => { + const data = await API.production().catch(() => ({ list: [] })); + const items = (data.list || []).map(r => `
  • ${r.batch || '批次'}${r.duration || '—'}
  • `).join(''); + return `
    生产时间记录
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/qa.js b/frontend/js/components/qa.js new file mode 100644 index 0000000..7fd39df --- /dev/null +++ b/frontend/js/components/qa.js @@ -0,0 +1,5 @@ +Router.register('/qa', async () => { + const data = await API.qa().catch(() => ({ list: [] })); + const items = (data.list || []).map(q => `
  • ${q.title || '报告'}${q.date || ''}
  • `).join(''); + return `
    质检报告
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/settings.js b/frontend/js/components/settings.js new file mode 100644 index 0000000..ad3c60b --- /dev/null +++ b/frontend/js/components/settings.js @@ -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=>`
  • ${u.username}${u.role}
  • `).join('') || '
  • 暂无用户
  • '; + const html = `
    +
    +
    外观设置
    +
    + +
    +
    + +
    +
    +
    +
    账户设置
    +
    +
    当前用户
    +
    ${(me && me.username) ? me.username : '未登录'}
    +
    +
    +
    头像设置
    +
    + 头像预览 +
    +
    + + +
    +
    支持 JPG、PNG、GIF 等图片格式,建议尺寸 200x200 像素
    +
    +
    +
    + + +
    +
    +
    + ${(me && me.role === 'superadmin') ? ` +
    +
    用户管理
    + +
    +
    添加新用户
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    修改用户密码
    +
    + + +
    +
    + + +
    +
    +
    + +
    超级管理员工具
    +
    +
    +
    +
    清空上传数据
    +
    + + + + +
    +
    +
    +
    清空扩展采集
    +
    + + + + + +
    +
    +
    +
    +
    +
    +
    数据概览
    +
    +
    +
    良/不良统计:加载中
    +
    不良明细:加载中
    +
    MAC与批次:加载中
    +
    +
    +
    发货记录:加载中
    +
    设备状态:加载中
    +
    人员信息:加载中
    +
    质检报告:加载中
    +
    生产时间:加载中
    +
    +
    +
    +
    +
    用户列表
      ${userList}
    +
    + ` : ''} +
    `; + 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; +}); \ No newline at end of file diff --git a/frontend/js/components/shipment-query.js b/frontend/js/components/shipment-query.js new file mode 100644 index 0000000..fc90610 --- /dev/null +++ b/frontend/js/components/shipment-query.js @@ -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 = ` +
    + 数据库出货数量: + ${data.count} +
    + `; + } + } catch (e) { + statsDiv.innerHTML = '
    无法获取统计信息
    '; + } + }; + + // 初始加载统计 + await loadStats(); + + // 根据用户角色控制清空按钮的显示 + if (clearBtn) { + if (userRole !== 'superadmin') { + clearBtn.style.display = 'none'; + } + } + + const performQuery = async () => { + const sn = snInput?.value?.trim(); + if (!sn) { + resultDiv.innerHTML = '
    请输入 SN/MAC 号
    '; + return; + } + + try { + resultDiv.innerHTML = '
    查询中...
    '; + + const res = await fetch(`/api/shipments/query-by-sn?sn=${encodeURIComponent(sn)}`, { + credentials: 'include' + }); + const data = await res.json(); + + if (data.found) { + resultDiv.innerHTML = ` +
    +
    ✓ 找到出货记录
    +
    SN/MAC: ${data.sn}
    +
    机种: ${data.platform_name || '未知'}
    +
    出货日期: ${data.date}
    +
    箱号: ${data.box}
    +
    记录时间: ${data.ts}
    +
    + `; + } else { + resultDiv.innerHTML = ` +
    +
    ✗ 未找到记录
    +
    SN/MAC: ${data.sn}
    +
    ${data.message || '该 SN 没有出货记录'}
    +
    + `; + } + } catch (e) { + resultDiv.innerHTML = `
    查询失败:${e.message}
    `; + } + }; + + 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 `
    +
    SN/MAC 出货查询
    + +
    +
    +
    加载中...
    +
    +
    + +
    +
    + + +
    +
    + + ${showClearButton ? '' : ''} +
    +
    + +
    +
    `; +}); diff --git a/frontend/js/components/shipments.js b/frontend/js/components/shipments.js new file mode 100644 index 0000000..f538235 --- /dev/null +++ b/frontend/js/components/shipments.js @@ -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 `
    +
    发货记录
    + +
    +
    手动录入
    +
    + 用于快速录入发货汇总信息(不含详细SN) +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    详细记录批量导入
    +
    +
    文件格式要求:
    +
    • 必需列:出货日期、箱号、SN1、SN2、...、SN20
    +
    • 出货日期列支持合并单元格
    +
    • 支持格式:Excel (.xlsx, .xls) 或 CSV
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    `; +}); \ No newline at end of file diff --git a/frontend/js/components/sidebar.js b/frontend/js/components/sidebar.js new file mode 100644 index 0000000..1203a36 --- /dev/null +++ b/frontend/js/components/sidebar.js @@ -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'); + }); + }); +})(); \ No newline at end of file diff --git a/frontend/js/components/upload.js b/frontend/js/components/upload.js new file mode 100644 index 0000000..57e7b21 --- /dev/null +++ b/frontend/js/components/upload.js @@ -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 `
    ${title}
    ${inner}
    `; + } + + function filePicker(id,label,accept){ + return `
    `; + } + + function numberInput(id,label){return `
    `} + function textarea(id,label,placeholder=''){return `
    `} + + async function renderMac(){ + return section('MAC与批次',` +
    + + +
    + ${filePicker('mac-file','批量导入(Excel)','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel')} +
    + +
    +
    + 最新记录 +
    + + +
    +
    + +
    + `); + } + + async function renderStats(){ + return section('良/不良统计',` +
    +
    📊 数据说明
    +
    +
    直通良品数:一次检测就通过的产品数量
    +
    良品数:最终通过检测的产品总数(包含直通良品 + 返修后通过的产品)
    +
    不良品数:最终未通过检测的产品数量(报废或待返修)
    +
    + 💡 计算公式:
    + 直通良品率 = 直通良品数 / (良品数 + 不良品数) × 100%
    + 总良品率 = 良品数 / (良品数 + 不良品数) × 100% +
    +
    +
    +
    + + +
    + ${numberInput('fpy-good-count','直通良品数量(一次检测通过)')} + ${numberInput('good-count','良品数量(最终通过检测的总数)')} + ${numberInput('bad-count','不良品数量(最终未通过)')} +
    + + +
    +
    +
    +
    + 最新记录 +
    + + +
    +
    + +
    + `); + } + + async function renderRepairs(){ + return section('返修记录',` + ${numberInput('repair-qty','返修完成数量')} + ${textarea('repair-note','备注(可选)','例如:批次号、问题描述等')} +
    +
    +
    + 最新记录 + +
    + +
    + `); + } + + 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; ...')} +
    +
    最新记录
    + `); + } + + 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=>`
  • ${r.mac}${r.batch}
  • `).join(''); + } else if(listEl) { + listEl.innerHTML = '
  • 暂无数据
  • '; + } + },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 = `
  • ${platformText}直通良:${r.fpy_good||0} 良:${r.good} 不良:${r.bad}`; + if(r.details && r.details.length > 0){ + html += `${r.details.length}条明细`; + } + html += '
  • '; + return html; + }).join(''); + } else if(listEl) { + listEl.innerHTML = '
  • 暂无数据
  • '; + } + } 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 `
  • 数量: ${r.qty}${r.note||'无备注'}${ts}
  • `; + }).join('')||'
  • 暂无数据
  • '; + },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=>`
  • ${r.mac}${r.batch}
  • `).join('')||'
  • 暂无数据
  • '; + },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=>`
  • ${r.mac}${r.batch}${new Date(r.timestamp).toLocaleString('zh-CN')}
  • `).join(''); + API.toast(`显示全部 ${history.length} 条历史记录`); + } else { + listEl.innerHTML = '
  • 暂无历史记录
  • '; + } + } + }); + + // 清空显示按钮 + addListener(clearDisplayBtn, 'click', ()=>{ + const listEl = document.getElementById('mac-list'); + if(listEl){ + listEl.innerHTML = '
  • 已清空显示
  • '; + 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=>`
  • ${r.mac}${r.batch}
  • `).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 = `
  • ${platformText}直通良:${r.fpy_good||0} 良:${r.good} 不良:${r.bad}`; + if(r.details && r.details.length > 0){ + html += `${r.details.length}条明细`; + } + html += `${new Date(r.timestamp).toLocaleString('zh-CN')}
  • `; + return html; + }).join(''); + API.toast(`显示全部 ${history.length} 条历史记录`); + } else { + listEl.innerHTML = '
  • 暂无历史记录
  • '; + } + } + }); + + // 清空显示按钮 + addListener(clearDisplayBtn, 'click', ()=>{ + const listEl = document.getElementById('stats-list'); + if(listEl){ + listEl.innerHTML = '
  • 已清空显示
  • '; + 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 = `
  • ${platformName} - 直通良:${fpyGood} 良:${good} 不良:${bad}`; + if(details.length > 0){ + html += `${details.length}条明细`; + } + html += '
  • '; + 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 `
  • 数量: ${r.qty}${r.note||'无备注'}${ts}
  • `; + }).join(''); + API.toast(`显示全部 ${data.list.length} 条记录`); + } else { + listEl.innerHTML = '
  • 暂无记录
  • '; + } + } + }); + + 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 `
  • 数量: ${r.qty}${r.note||'无备注'}${ts}
  • `; + }).join('')||'
  • 暂无数据
  • '; + } + + // 清空输入 + 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=>`
  • ${r.mac}${r.batch}
  • `).join('')||'
  • 暂无数据
  • '; + }catch(e){ + API.toast('上传失败'); + } finally { + if (btn) btn.disabled = false; + } + }); + } +})(); diff --git a/frontend/js/router.js b/frontend/js/router.js new file mode 100644 index 0000000..b503201 --- /dev/null +++ b/frontend/js/router.js @@ -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 `${label(p)}`; + }).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 }; +})(); \ No newline at end of file diff --git a/frontend/js/utils/memory-monitor.js b/frontend/js/utils/memory-monitor.js new file mode 100644 index 0000000..f546ef8 --- /dev/null +++ b/frontend/js/utils/memory-monitor.js @@ -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; diff --git a/manager.svg b/manager.svg new file mode 100644 index 0000000..a7fc8fc --- /dev/null +++ b/manager.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/query_shipment_sn.sh b/query_shipment_sn.sh new file mode 100644 index 0000000..7ecc0c0 --- /dev/null +++ b/query_shipment_sn.sh @@ -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 的出货信息" + echo " count 查看总记录数" + echo " list [数量] 列出最近的 SN(默认 20 条)" + echo " search <模式> 搜索匹配的 SN(如: SN00*)" + echo " exists 检查 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 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..6fb5342 --- /dev/null +++ b/server/app.py @@ -0,0 +1,2263 @@ +# -*- coding: utf-8 -*- +import os +import json +import sqlite3 +from datetime import datetime +from functools import wraps +from flask import Flask, request, jsonify, session, send_from_directory +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +try: + import redis +except Exception: + redis = None +_redis_client = None +_audit_cache = {'pdd': {'ts': 0, 'list': []}, 'yt': {'ts': 0, 'list': []}} + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, 'data.db') +FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), 'frontend') + +app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path='') +app.config['SECRET_KEY'] = os.environ.get('APP_SECRET', 'change-me') + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + conn = get_db() + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL + )''') + c.execute('''CREATE TABLE IF NOT EXISTS operations_log( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT, + detail TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS notifications( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT, + action TEXT, + detail TEXT, + ts TEXT, + read INTEGER DEFAULT 0 + )''') + c.execute('''CREATE TABLE IF NOT EXISTS mac_batches( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT, + batch TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS stats( + id INTEGER PRIMARY KEY AUTOINCREMENT, + good INTEGER, + bad INTEGER, + fpy_good INTEGER DEFAULT 0, + platform TEXT DEFAULT 'pdd', + ts TEXT + )''') + # 为已存在的表添加列(如果不存在) + try: + c.execute('ALTER TABLE stats ADD COLUMN fpy_good INTEGER DEFAULT 0') + except Exception: + pass # 列已存在 + try: + c.execute('ALTER TABLE stats ADD COLUMN platform TEXT DEFAULT "pdd"') + except Exception: + pass # 列已存在 + try: + c.execute('ALTER TABLE users ADD COLUMN avatar TEXT') + except Exception: + pass # 列已存在 + c.execute('''CREATE TABLE IF NOT EXISTS defects( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT, + batch TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS shipments( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT, + qty INTEGER, + receiver TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS devices( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + status TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS environment( + id INTEGER PRIMARY KEY AUTOINCREMENT, + temp TEXT, + hum TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS personnel( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + role TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS qa( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + date TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS production( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch TEXT, + duration TEXT, + ts TEXT + )''') + c.execute('''CREATE TABLE IF NOT EXISTS repairs( + id INTEGER PRIMARY KEY AUTOINCREMENT, + qty INTEGER, + note TEXT, + ts TEXT + )''') + conn.commit() + # create default admin + c.execute('SELECT id FROM users WHERE username=?', ('admin',)) + if not c.fetchone(): + pwd = os.environ.get('ADMIN_PASSWORD', 'admin123') + c.execute('INSERT INTO users(username, password_hash, role) VALUES(?,?,?)', ( + 'admin', generate_password_hash(pwd), 'admin' + )) + conn.commit() + # create superadmin from env + su_user = os.environ.get('SUPERADMIN_USERNAME') + su_pass = os.environ.get('SUPERADMIN_PASSWORD') + if su_user and su_pass: + c.execute('SELECT id FROM users WHERE username=?', (su_user,)) + if not c.fetchone(): + c.execute('INSERT INTO users(username, password_hash, role) VALUES(?,?,?)', ( + su_user, generate_password_hash(su_pass), 'superadmin' + )) + conn.commit() + conn.close() + + +def log(action, detail=''): + try: + conn = get_db() + c = conn.cursor() + c.execute('INSERT INTO operations_log(user_id, action, detail, ts) VALUES(?,?,?,?)', ( + session.get('user_id'), action, detail, datetime.utcnow().isoformat() + )) + conn.commit() + conn.close() + except Exception: + pass + + +def notify_superadmin(action, detail=''): + """为超级管理员创建通知""" + try: + user_id = session.get('user_id') + if not user_id: + return + + conn = get_db() + c = conn.cursor() + + # 获取当前用户信息 + c.execute('SELECT username, role FROM users WHERE id=?', (user_id,)) + user = c.fetchone() + if not user: + conn.close() + return + + # 如果是超级管理员自己的操作,不创建通知 + if user['role'] == 'superadmin': + conn.close() + return + + # 为所有超级管理员创建通知 + c.execute('SELECT id FROM users WHERE role=?', ('superadmin',)) + superadmins = c.fetchall() + + # 使用北京时间(UTC+8) + from datetime import timezone, timedelta + beijing_tz = timezone(timedelta(hours=8)) + now = datetime.now(beijing_tz).isoformat() + + for admin in superadmins: + c.execute('INSERT INTO notifications(user_id, username, action, detail, ts, read) VALUES(?,?,?,?,?,?)', ( + admin['id'], user['username'], action, detail, now, 0 + )) + + conn.commit() + conn.close() + except Exception: + pass + + +def get_redis(): + global _redis_client + if not redis: + raise RuntimeError('redis missing') + if _redis_client is not None: + return _redis_client + host = os.environ.get('REDIS_HOST', '180.163.74.83') + port = int(os.environ.get('REDIS_PORT', '6379')) + password = os.environ.get('REDIS_PASSWORD') or os.environ.get('SUPERADMIN_PASSWORD') + db = int(os.environ.get('REDIS_DB', '0')) + _redis_client = redis.Redis(host=host, port=port, password=password, db=db, decode_responses=True, socket_timeout=0.5, socket_connect_timeout=0.5) + return _redis_client + +def parse_audit_line(s): + if not s: + return {'ts_cn': None, 'batch': None, 'mac': None, 'note': None} + def normalize_ts(ts): + try: + from datetime import datetime, timezone, timedelta + # Attempt ISO parsing + # Support "Z" UTC suffix and offsets like +08:00 + if ts.endswith('Z'): + dt = datetime.fromisoformat(ts.replace('Z', '+00:00')) + else: + dt = datetime.fromisoformat(ts) + # Convert to Beijing time (UTC+8) + bj = dt.astimezone(timezone(timedelta(hours=8))) + return bj.strftime('%Y-%m-%d %H:%M:%S') + except Exception: + return ts + def has_time(v): + return isinstance(v, str) and (('T' in v) or (':' in v)) + def choose_ts(d): + candidates = [d.get('ts_cn'), d.get('ts_local'), d.get('ts'), d.get('ts_utc'), d.get('timestamp'), d.get('time')] + for v in candidates: + if has_time(v): + return v + for v in candidates: + if v: + return v + return None + try: + obj = json.loads(s) + ts = choose_ts(obj) + if not ts: + ts = s if isinstance(s, str) else None + return { + 'ts_cn': ts if ts else None, + 'batch': obj.get('batch') or obj.get('batch_no') or obj.get('lot'), + 'mac': obj.get('mac') or obj.get('mac_addr') or obj.get('mac_address'), + 'note': obj.get('note') or obj.get('msg') or obj.get('message') + } + except Exception: + pass + d = {} + parts = [] + for sep in [' ', ',', ';', '|']: + if sep in s: + parts = s.split(sep) + break + if not parts: + parts = [s] + i = 0 + while i < len(parts): + part = parts[i] + if '=' in part: + k, v = part.split('=', 1) + kk = k.strip() + vv = v.strip() + try: + import re + if kk in ('ts_cn', 'ts_local', 'ts', 'timestamp', 'time'): + if re.match(r'^\d{4}-\d{2}-\d{2}$', vv) and i + 1 < len(parts) and re.match(r'^\d{2}:\d{2}:\d{2}', parts[i+1]): + vv = vv + ' ' + parts[i+1] + i += 1 + except Exception: + pass + d[kk] = vv + i += 1 + ts = choose_ts(d) + if not ts: + ts = s if isinstance(s, str) else None + return { + 'ts_cn': ts if ts else None, + 'batch': d.get('batch') or d.get('batch_no') or d.get('lot'), + 'mac': d.get('mac') or d.get('mac_addr') or d.get('mac_address'), + 'note': d.get('note') or d.get('msg') or d.get('message') or s + } + + +def require_login(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if not session.get('user_id'): + return jsonify({'error': 'unauthorized'}), 401 + return fn(*args, **kwargs) + return wrapper + + +def require_role(role): + def deco(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if session.get('role') != role: + return jsonify({'error': 'forbidden'}), 403 + return fn(*args, **kwargs) + return wrapper + return deco + +def require_any_role(*roles): + def deco(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if session.get('role') not in roles: + return jsonify({'error': 'forbidden'}), 403 + return fn(*args, **kwargs) + return wrapper + return deco + + +@app.route('/') +def index(): + return send_from_directory(FRONTEND_DIR, 'index.html') + + +# auth +@app.post('/api/auth/login') +def login(): + data = request.get_json() or {} + username = data.get('username') + password = data.get('password') + conn = get_db() + c = conn.cursor() + c.execute('SELECT id, password_hash, role FROM users WHERE username=?', (username,)) + row = c.fetchone() + conn.close() + if not row or not check_password_hash(row['password_hash'], password or ''): + return jsonify({'error': 'invalid credentials'}), 400 + session['user_id'] = row['id'] + session['role'] = row['role'] + session.permanent = True + log('login', username) + return jsonify({'ok': True}) + + +@app.get('/api/auth/me') +def me(): + uid = session.get('user_id') + if not uid: + return jsonify({'username': None, 'role': None, 'avatar': None}) + conn = get_db() + c = conn.cursor() + c.execute('SELECT username, role, avatar FROM users WHERE id=?', (uid,)) + row = c.fetchone() + conn.close() + return jsonify({'username': row['username'], 'role': row['role'], 'avatar': row['avatar'] if row['avatar'] else None}) + + +@app.post('/api/auth/logout') +def logout(): + log('logout') + session.clear() + return jsonify({'ok': True}) + + +@app.post('/api/user/upload-avatar') +@require_login +def upload_avatar(): + uid = session.get('user_id') + if not uid: + return jsonify({'error': '未登录'}), 401 + + if 'avatar' not in request.files: + return jsonify({'error': '未选择文件'}), 400 + + file = request.files['avatar'] + if file.filename == '': + return jsonify({'error': '未选择文件'}), 400 + + # 验证文件类型 + allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + filename = secure_filename(file.filename) + if '.' not in filename: + return jsonify({'error': '无效的文件格式'}), 400 + + ext = filename.rsplit('.', 1)[1].lower() + if ext not in allowed_extensions: + return jsonify({'error': '不支持的文件格式,请上传 PNG、JPG、GIF 或 WEBP 格式'}), 400 + + # 创建avatars目录 + avatars_dir = os.path.join(FRONTEND_DIR, 'assets', 'avatars') + os.makedirs(avatars_dir, exist_ok=True) + + # 生成唯一文件名 + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + new_filename = f'avatar_{uid}_{timestamp}.{ext}' + filepath = os.path.join(avatars_dir, new_filename) + + # 保存文件 + file.save(filepath) + + # 更新数据库 + avatar_url = f'./assets/avatars/{new_filename}' + conn = get_db() + c = conn.cursor() + + # 删除旧头像文件(如果存在) + c.execute('SELECT avatar FROM users WHERE id=?', (uid,)) + row = c.fetchone() + if row and row['avatar'] and row['avatar'].startswith('./assets/avatars/'): + old_file = os.path.join(FRONTEND_DIR, row['avatar'].replace('./', '')) + if os.path.exists(old_file): + try: + os.remove(old_file) + except Exception: + pass + + c.execute('UPDATE users SET avatar=? WHERE id=?', (avatar_url, uid)) + conn.commit() + conn.close() + + log('upload_avatar', f'上传头像: {new_filename}') + return jsonify({'ok': True, 'avatar_url': avatar_url}) + + +@app.post('/api/user/reset-avatar') +@require_login +def reset_avatar(): + uid = session.get('user_id') + if not uid: + return jsonify({'error': '未登录'}), 401 + + conn = get_db() + c = conn.cursor() + + # 删除旧头像文件(如果存在) + c.execute('SELECT avatar FROM users WHERE id=?', (uid,)) + row = c.fetchone() + if row and row['avatar'] and row['avatar'].startswith('./assets/avatars/'): + old_file = os.path.join(FRONTEND_DIR, row['avatar'].replace('./', '')) + if os.path.exists(old_file): + try: + os.remove(old_file) + except Exception: + pass + + c.execute('UPDATE users SET avatar=NULL WHERE id=?', (uid,)) + conn.commit() + conn.close() + + log('reset_avatar', '恢复默认头像') + return jsonify({'ok': True}) + + +# dashboard +@app.get('/api/dashboard') +@require_login +def dashboard(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT COALESCE(SUM(good),0) AS good_total, COALESCE(SUM(bad),0) AS bad_total, COALESCE(SUM(fpy_good),0) AS fpy_good_total FROM stats') + s = c.fetchone() + c.execute('SELECT COUNT(1) AS total FROM defects') + defects = c.fetchone() + conn.close() + + good = s['good_total'] if s else 0 + bad = s['bad_total'] if s else 0 + fpy_good = s['fpy_good_total'] if s else 0 + + # 计算总良品率 + rate = "{}%".format(round((good/(good+bad)) * 100, 2)) if (good+bad) > 0 else u'—' + + # 计算直通良品率(FPY = First Pass Yield) + total_produced = good + bad + fpy_rate = "{}%".format(round((fpy_good/total_produced) * 100, 2)) if total_produced > 0 else u'—' + + # 从 Redis 获取发货数量(SN 记录数) + shipments_count = 0 + try: + r = get_redis() + redis_key = 'shipment_sn_mapping' + shipments_count = r.hlen(redis_key) + except Exception as e: + log('dashboard_redis_error', str(e)) + # Redis 失败时回退到 SQLite + conn = get_db() + c = conn.cursor() + c.execute('SELECT SUM(qty) AS total FROM shipments') + ship = c.fetchone() + conn.close() + shipments_count = (ship['total'] or 0) if ship else 0 + + return jsonify({ + 'fpyRate': fpy_rate, + 'goodRate': rate, + 'shipments': shipments_count, + 'defects': (defects['total'] or 0) if defects else 0, + 'badCount': bad + }) + + +@app.get('/api/audit/pdd') +@require_login +def audit_pdd(): + start = datetime.utcnow() + try: + q_start = request.args.get('start') + q_end = request.args.get('end') + q_limit = request.args.get('limit') + q_order = request.args.get('order', 'desc') + has_filter = bool(q_start or q_end or q_limit or q_order) + + # 缓存优化:3秒内不重复查询 + if (not has_filter) and ((datetime.utcnow().timestamp() - _audit_cache['pdd']['ts']) < 3): + return jsonify({'list': _audit_cache['pdd']['list']}) + + r = get_redis() + # 设置Redis超时为5秒 + r.connection_pool.connection_kwargs['socket_timeout'] = 5 + + items = [] + # 限制最大返回数量,避免数据过大 + max_items = 500 if has_filter else 200 + + for key in ['mac_batch_audit_pdd', 'audit:pdd', 'pdd:audit']: + try: + if r.exists(key): + t = r.type(key) + if t == 'list': + # 限制最大查询数量 + total = r.llen(key) + if has_filter: + items = r.lrange(key, max(0, total - max_items), -1) + else: + items = r.lrange(key, -200, -1) + elif t == 'zset': + items = r.zrevrange(key, 0, max_items - 1) + elif t == 'stream': + entries = r.xrevrange(key, max='+', min='-', count=max_items) + items = [json.dumps(v) for _id, v in entries] + else: + v = r.get(key) + items = [v] if v else [] + break + except Exception as e: + log('audit_pdd_error', f'Redis query error: {str(e)}') + continue + try: + host = os.environ.get('REDIS_HOST') + db = os.environ.get('REDIS_DB') + tp = r.type('mac_batch_audit_pdd') + ln = 0 + try: + ln = r.llen('mac_batch_audit_pdd') + except Exception: + pass + log('audit_pdd_probe', json.dumps({'host': host, 'db': db, 'type': tp, 'len': ln})) + except Exception: + pass + if not items and r.exists('batch_sn_mapping_pdd') and r.type('batch_sn_mapping_pdd') == 'hash': + try: + pairs = [] + cursor = 0 + while True: + cursor, res = r.hscan('batch_sn_mapping_pdd', cursor=cursor, count=200) + for k, v in (res or {}).items(): + pairs.append({'mac': k, 'batch': v}) + if len(pairs) >= 100: + break + if cursor == 0 or len(pairs) >= 100: + break + items = [json.dumps({'mac': p['mac'], 'batch': p['batch'], 'note': 'mapping'}) for p in pairs] + except Exception: + pass + res = [parse_audit_line(x) for x in items] + if q_start or q_end: + def to_epoch(s): + try: + if not s: + return None + if 'T' in s or 'Z' in s or '+' in s: + return datetime.fromisoformat(s.replace('Z','+00:00')).timestamp() + if ' ' in s and ':' in s: + return datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timestamp() + return datetime.strptime(s, '%Y-%m-%d').timestamp() + except Exception: + return None + s_epoch = to_epoch(q_start) if q_start else None + e_epoch = to_epoch(q_end) if q_end else None + tmp = [] + for r0 in res: + ts = to_epoch(r0.get('ts_cn')) + if ts is None: + continue + if s_epoch is not None and ts < s_epoch: + continue + if e_epoch is not None and ts > e_epoch: + continue + tmp.append(r0) + res = tmp + try: + def to_key(r): + s = r.get('ts_cn') or '' + try: + if 'T' in s or 'Z' in s or '+' in s: + return datetime.fromisoformat(s.replace('Z','+00:00')).timestamp() + if ' ' in s and ':' in s: + return datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timestamp() + return datetime.strptime(s, '%Y-%m-%d').timestamp() + except Exception: + return 0 + res.sort(key=to_key, reverse=(q_order != 'asc')) + except Exception: + res.reverse() + if q_limit: + try: + lim = int(q_limit) + if lim > 0: + res = res[:lim] + except Exception: + pass + if not has_filter: + _audit_cache['pdd'] = {'ts': datetime.utcnow().timestamp(), 'list': res} + dur = (datetime.utcnow() - start).total_seconds() + log('audit_pdd_cost', f"{dur}s len={len(res)}") + return jsonify({'list': res}) + except Exception as e: + log('audit_pdd_error', str(e)) + return jsonify({'list': []}) + + +@app.get('/api/audit/yt') +@require_login +def audit_yt(): + start = datetime.utcnow() + try: + q_start = request.args.get('start') + q_end = request.args.get('end') + q_limit = request.args.get('limit') + q_order = request.args.get('order', 'desc') + has_filter = bool(q_start or q_end or q_limit or q_order) + + # 缓存优化:3秒内不重复查询 + if (not has_filter) and ((datetime.utcnow().timestamp() - _audit_cache['yt']['ts']) < 3): + return jsonify({'list': _audit_cache['yt']['list']}) + + r = get_redis() + # 设置Redis超时为5秒 + r.connection_pool.connection_kwargs['socket_timeout'] = 5 + + items = [] + # 限制最大返回数量,避免数据过大 + max_items = 500 if has_filter else 200 + + for key in ['mac_batch_audit_yt', 'audit:yt', 'yt:audit']: + try: + if r.exists(key): + t = r.type(key) + if t == 'list': + # 限制最大查询数量 + total = r.llen(key) + if has_filter: + items = r.lrange(key, max(0, total - max_items), -1) + else: + items = r.lrange(key, -200, -1) + elif t == 'zset': + items = r.zrevrange(key, 0, max_items - 1) + elif t == 'stream': + entries = r.xrevrange(key, max='+', min='-', count=max_items) + items = [json.dumps(v) for _id, v in entries] + else: + v = r.get(key) + items = [v] if v else [] + break + except Exception as e: + log('audit_yt_error', f'Redis query error: {str(e)}') + continue + try: + host = os.environ.get('REDIS_HOST') + db = os.environ.get('REDIS_DB') + tp = r.type('mac_batch_audit_yt') + ln = 0 + try: + ln = r.llen('mac_batch_audit_yt') + except Exception: + pass + log('audit_yt_probe', json.dumps({'host': host, 'db': db, 'type': tp, 'len': ln})) + except Exception: + pass + if not items and r.exists('batch_sn_mapping_yt') and r.type('batch_sn_mapping_yt') == 'hash': + try: + pairs = [] + cursor = 0 + while True: + cursor, res = r.hscan('batch_sn_mapping_yt', cursor=cursor, count=200) + for k, v in (res or {}).items(): + pairs.append({'mac': k, 'batch': v}) + if len(pairs) >= 100: + break + if cursor == 0 or len(pairs) >= 100: + break + items = [json.dumps({'mac': p['mac'], 'batch': p['batch'], 'note': 'mapping'}) for p in pairs] + except Exception: + pass + res = [parse_audit_line(x) for x in items] + if q_start or q_end: + def to_epoch(s): + try: + if not s: + return None + if 'T' in s or 'Z' in s or '+' in s: + return datetime.fromisoformat(s.replace('Z','+00:00')).timestamp() + if ' ' in s and ':' in s: + return datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timestamp() + return datetime.strptime(s, '%Y-%m-%d').timestamp() + except Exception: + return None + s_epoch = to_epoch(q_start) if q_start else None + e_epoch = to_epoch(q_end) if q_end else None + tmp = [] + for r0 in res: + ts = to_epoch(r0.get('ts_cn')) + if ts is None: + continue + if s_epoch is not None and ts < s_epoch: + continue + if e_epoch is not None and ts > e_epoch: + continue + tmp.append(r0) + res = tmp + try: + def to_key(r): + s = r.get('ts_cn') or '' + try: + if 'T' in s or 'Z' in s or '+' in s: + return datetime.fromisoformat(s.replace('Z','+00:00')).timestamp() + if ' ' in s and ':' in s: + return datetime.strptime(s, '%Y-%m-%d %H:%M:%S').timestamp() + return datetime.strptime(s, '%Y-%m-%d').timestamp() + except Exception: + return 0 + res.sort(key=to_key, reverse=(q_order != 'asc')) + except Exception: + res.reverse() + if q_limit: + try: + lim = int(q_limit) + if lim > 0: + res = res[:lim] + except Exception: + pass + if not has_filter: + _audit_cache['yt'] = {'ts': datetime.utcnow().timestamp(), 'list': res} + dur = (datetime.utcnow() - start).total_seconds() + log('audit_yt_cost', f"{dur}s len={len(res)}") + return jsonify({'list': res}) + except Exception as e: + log('audit_yt_error', str(e)) + return jsonify({'list': []}) + + +@app.get('/api/audit/diagnose') +@require_login +def audit_diagnose(): + try: + r = get_redis() + result = {} + for key in ['mac_batch_audit_pdd', 'mac_batch_audit_yt', 'batch_sn_mapping_pdd', 'batch_sn_mapping_yt']: + try: + t = r.type(key) + if t == 'list': + result[key] = {'type': t, 'len': r.llen(key)} + elif t == 'zset': + result[key] = {'type': t, 'len': r.zcard(key)} + elif t == 'stream': + info = r.xinfo_stream(key) + result[key] = {'type': t, 'len': info.get('length')} + elif t == 'hash': + result[key] = {'type': t, 'len': r.hlen(key)} + elif t == 'none': + result[key] = {'type': t, 'len': 0} + else: + v = r.get(key) + result[key] = {'type': t, 'len': 1 if v else 0} + except Exception as e: + result[key] = {'error': str(e)} + return jsonify(result) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + + +@app.get('/api/overview') +@require_login +def overview(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT COUNT(1) AS cnt, COALESCE(SUM(good),0) AS good_total, COALESCE(SUM(bad),0) AS bad_total, COALESCE(SUM(fpy_good),0) AS fpy_good_total FROM stats') + stats_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt FROM defects') + defects_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt FROM mac_batches') + mac_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt, COALESCE(SUM(qty),0) AS qty_total FROM shipments') + ship_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt FROM devices') + devices_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt FROM personnel') + personnel_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt FROM qa') + qa_row = c.fetchone() + c.execute('SELECT COUNT(1) AS cnt FROM production') + production_row = c.fetchone() + conn.close() + return jsonify({ + 'stats': { + 'records': (stats_row['cnt'] or 0) if stats_row else 0, + 'goodTotal': (stats_row['good_total'] or 0) if stats_row else 0, + 'badTotal': (stats_row['bad_total'] or 0) if stats_row else 0, + 'fpyGoodTotal': (stats_row['fpy_good_total'] or 0) if stats_row else 0 + }, + 'defects': (defects_row['cnt'] or 0) if defects_row else 0, + 'mac': (mac_row['cnt'] or 0) if mac_row else 0, + 'shipments': { + 'records': (ship_row['cnt'] or 0) if ship_row else 0, + 'qtyTotal': (ship_row['qty_total'] or 0) if ship_row else 0 + }, + 'devices': (devices_row['cnt'] or 0) if devices_row else 0, + 'personnel': (personnel_row['cnt'] or 0) if personnel_row else 0, + 'qa': (qa_row['cnt'] or 0) if qa_row else 0, + 'production': (production_row['cnt'] or 0) if production_row else 0 + }) + + +# uploads +@app.post('/api/upload/mac') +@require_login +@require_any_role('admin','superadmin') +def upload_mac(): + data = request.get_json() or {} + rows = data.get('rows') or [] + if not isinstance(rows, list): + return jsonify({'error': 'invalid rows'}), 400 + conn = get_db() + c = conn.cursor() + now = datetime.utcnow().isoformat() + for r in rows: + mac = (r or {}).get('mac') + batch = (r or {}).get('batch') + if not mac or not batch: + continue + c.execute('INSERT INTO mac_batches(mac, batch, ts) VALUES(?,?,?)', (mac, batch, now)) + conn.commit() + conn.close() + log('upload_mac', f"count={len(rows)}") + notify_superadmin('上传MAC与批次', f"上传了 {len(rows)} 条记录") + return jsonify({'ok': True}) + + +@app.post('/api/upload/stats') +@require_login +@require_any_role('admin','superadmin') +def upload_stats(): + data = request.get_json() or {} + good = int(data.get('good') or 0) + bad = int(data.get('bad') or 0) + fpy_good = int(data.get('fpy_good') or 0) # 直通良品数 + platform = data.get('platform') or 'pdd' # 平台:pdd/yt/tx + details = data.get('details') or [] + + if good < 0 or bad < 0 or fpy_good < 0: + return jsonify({'error': 'invalid count'}), 400 + + if platform not in ['pdd', 'yt', 'tx']: + platform = 'pdd' + + conn = get_db() + c = conn.cursor() + now = datetime.utcnow().isoformat() + + # 保存统计数据 + c.execute('INSERT INTO stats(good,bad,fpy_good,platform,ts) VALUES(?,?,?,?,?)', (good, bad, fpy_good, platform, now)) + + # 如果有不良明细,保存到defects表 + if details and isinstance(details, list): + for item in details: + mac = (item or {}).get('mac') + batch = (item or {}).get('batch') + if mac and batch: + c.execute('INSERT INTO defects(mac, batch, ts) VALUES(?,?,?)', (mac, batch, now)) + + conn.commit() + conn.close() + + platform_name = {'pdd': '拼多多', 'yt': '圆通', 'tx': '兔喜'}.get(platform, platform) + log('upload_stats', json.dumps({'good': good, 'bad': bad, 'fpy_good': fpy_good, 'platform': platform, 'details_count': len(details)})) + notify_superadmin('上传良/不良统计', f"平台: {platform_name}, 良品: {good}, 不良品: {bad}, 直通良品: {fpy_good}") + return jsonify({'ok': True}) + + +@app.post('/api/upload/repairs') +@require_login +@require_any_role('admin','superadmin') +def upload_repairs(): + data = request.get_json() or {} + qty = int(data.get('qty') or 0) + note = data.get('note') or '' + + if qty < 0: + return jsonify({'error': 'invalid quantity'}), 400 + + conn = get_db() + c = conn.cursor() + now = datetime.utcnow().isoformat() + + c.execute('INSERT INTO repairs(qty, note, ts) VALUES(?,?,?)', (qty, note, now)) + conn.commit() + conn.close() + + log('upload_repairs', json.dumps({'qty': qty, 'note': note})) + notify_superadmin('上传返修记录', f"数量: {qty}") + return jsonify({'ok': True}) + + +@app.post('/api/upload/defects') +@require_login +@require_any_role('admin','superadmin') +def upload_defects(): + data = request.get_json() or {} + rows = data.get('rows') or [] + conn = get_db() + c = conn.cursor() + now = datetime.utcnow().isoformat() + for r in rows: + mac = (r or {}).get('mac') + batch = (r or {}).get('batch') + if not mac or not batch: + continue + c.execute('INSERT INTO defects(mac, batch, ts) VALUES(?,?,?)', (mac, batch, now)) + conn.commit() + conn.close() + log('upload_defects', f"count={len(rows)}") + notify_superadmin('上传不良明细', f"上传了 {len(rows)} 条记录") + return jsonify({'ok': True}) + + +@app.post('/api/upload/shipments') +@require_login +@require_any_role('admin','superadmin') +def upload_shipments(): + data = request.get_json() or {} + date = data.get('date') + qty = int(data.get('qty') or 0) + to = data.get('to') + platform = data.get('platform', '') + box_no = data.get('box_no', '') + + if not date or qty <= 0 or not to or not platform: + return jsonify({'error': 'invalid payload'}), 400 + + conn = get_db() + c = conn.cursor() + + # 检查shipments表是否有platform和box_no列,如果没有则添加 + c.execute("PRAGMA table_info(shipments)") + columns = [col[1] for col in c.fetchall()] + + if 'platform' not in columns: + c.execute('ALTER TABLE shipments ADD COLUMN platform TEXT') + if 'box_no' not in columns: + c.execute('ALTER TABLE shipments ADD COLUMN box_no TEXT') + + c.execute( + 'INSERT INTO shipments(date, qty, receiver, platform, box_no, ts) VALUES(?,?,?,?,?,?)', + (date, qty, to, platform, box_no, datetime.utcnow().isoformat()) + ) + conn.commit() + conn.close() + + platform_name = {'pdd': '拼多多', 'yt': '圆通', 'tx': '兔喜'}.get(platform, platform) + log_data = {'date': date, 'qty': qty, 'to': to, 'platform': platform} + if box_no: + log_data['box_no'] = box_no + + log('upload_shipments', json.dumps(log_data)) + notify_superadmin('上传发货记录', f"机种: {platform_name}, 日期: {date}, 数量: {qty}, 接收方: {to}") + return jsonify({'ok': True}) + + +# collect +@app.get('/api/collect/devices') +@require_login +def devices(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT name, status FROM devices ORDER BY id DESC LIMIT 50') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.get('/api/collect/environment') +@require_login +def environment(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT temp, hum FROM environment ORDER BY id DESC LIMIT 1') + r = c.fetchone() + conn.close() + return jsonify({'temp': (r['temp'] if r else '—'), 'hum': (r['hum'] if r else '—')}) + + +@app.get('/api/collect/personnel') +@require_login +def personnel(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT name, role FROM personnel ORDER BY id DESC LIMIT 100') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.post('/api/collect/personnel') +@require_login +@require_any_role('admin','superadmin') +def add_personnel(): + data = request.get_json() or {} + name = (data.get('name') or '').strip() + role = (data.get('role') or '').strip() + if not name: + return jsonify({'error': 'invalid payload'}), 400 + conn = get_db() + c = conn.cursor() + c.execute('INSERT INTO personnel(name, role, ts) VALUES(?,?,?)', (name, role, datetime.utcnow().isoformat())) + conn.commit() + conn.close() + log('add_personnel', name) + notify_superadmin('添加人员信息', f"姓名: {name}, 角色: {role}") + return jsonify({'ok': True}) + + +@app.get('/api/collect/qa') +@require_login +def qa(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT title, date FROM qa ORDER BY id DESC LIMIT 100') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.get('/api/collect/production') +@require_login +def production(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT batch, duration FROM production ORDER BY id DESC LIMIT 100') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +# export +@app.post('/api/export/excel') +@require_login +def export_excel(): + try: + import openpyxl + from openpyxl.styles import Font, Alignment, PatternFill + from io import BytesIO + + data = request.get_json() or {} + data_type = data.get('type', 'stats') + + # 创建工作簿 + wb = openpyxl.Workbook() + ws = wb.active + + # 设置标题样式 + header_fill = PatternFill(start_color='4F8CFF', end_color='4F8CFF', fill_type='solid') + header_font = Font(bold=True, color='FFFFFF') + header_alignment = Alignment(horizontal='center', vertical='center') + + conn = get_db() + c = conn.cursor() + + # 根据类型导出不同的数据 + if data_type == 'stats': + ws.title = '良不良统计' + ws.append(['直通良品数', '良品数', '不良品数', '时间']) + c.execute('SELECT fpy_good, good, bad, ts FROM stats ORDER BY id DESC') + elif data_type == 'mac': + ws.title = 'MAC与批次' + ws.append(['MAC地址', '批次号', '时间']) + c.execute('SELECT mac, batch, ts FROM mac_batches ORDER BY id DESC') + elif data_type == 'repairs': + ws.title = '返修记录' + ws.append(['返修数量', '备注', '时间']) + c.execute('SELECT qty, note, ts FROM repairs ORDER BY id DESC') + elif data_type == 'defects': + ws.title = '不良明细' + ws.append(['MAC地址', '批次号', '时间']) + c.execute('SELECT mac, batch, ts FROM defects ORDER BY id DESC') + elif data_type == 'shipments': + ws.title = '发货记录' + ws.append(['日期', '数量', '收货方', '时间']) + c.execute('SELECT date, qty, receiver, ts FROM shipments ORDER BY id DESC') + elif data_type == 'devices': + ws.title = '设备状态' + ws.append(['设备名称', '状态']) + c.execute('SELECT name, status FROM devices ORDER BY id DESC') + elif data_type == 'personnel': + ws.title = '人员信息' + ws.append(['姓名', '角色']) + c.execute('SELECT name, role FROM personnel ORDER BY id DESC') + elif data_type == 'qa': + ws.title = '质检报告' + ws.append(['标题', '日期']) + c.execute('SELECT title, date FROM qa ORDER BY id DESC') + elif data_type == 'production': + ws.title = '时间记录' + ws.append(['批次', '时长']) + c.execute('SELECT batch, duration FROM production ORDER BY id DESC') + else: + conn.close() + return jsonify({'error': 'invalid type'}), 400 + + # 应用标题样式 + for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = header_alignment + + # 写入数据 + rows = c.fetchall() + for row in rows: + ws.append(list(row)) + + conn.close() + + # 自动调整列宽 + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # 保存到内存 + output = BytesIO() + wb.save(output) + output.seek(0) + + log('export_excel', data_type) + + # 返回文件 + from flask import send_file + filename = f'{ws.title}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx' + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + except Exception as e: + log('export_excel_error', str(e)) + return jsonify({'error': str(e)}), 500 + + +@app.post('/api/export/pdf') +@require_login +def export_pdf(): + try: + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4, landscape + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import cm + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.lib.enums import TA_CENTER + from io import BytesIO + + data = request.get_json() or {} + data_type = data.get('type', 'stats') + + # 注册中文字体 + font_name = 'Helvetica' + try: + # 尝试常见的中文字体路径 + font_paths = [ + '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', + '/usr/share/fonts/truetype/arphic/uming.ttc', + '/System/Library/Fonts/PingFang.ttc', + 'C:\\Windows\\Fonts\\simhei.ttf' + ] + for font_path in font_paths: + if os.path.exists(font_path): + pdfmetrics.registerFont(TTFont('ChineseFont', font_path)) + font_name = 'ChineseFont' + break + except Exception as e: + log('pdf_font_warning', f'无法加载中文字体: {str(e)}') + + # 创建PDF + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=landscape(A4), + topMargin=1.5*cm, + bottomMargin=1.5*cm, + leftMargin=1.5*cm, + rightMargin=1.5*cm + ) + elements = [] + + # 样式 + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontName=font_name, + fontSize=18, + textColor=colors.HexColor('#4F8CFF'), + spaceAfter=20, + alignment=TA_CENTER, + leading=24 + ) + + conn = get_db() + c = conn.cursor() + + # 根据类型导出不同的数据 + if data_type == 'stats': + title = '良/不良统计报表' + headers = ['直通良品数', '良品数', '不良品数', '时间'] + c.execute('SELECT fpy_good, good, bad, ts FROM stats ORDER BY id DESC LIMIT 200') + elif data_type == 'mac': + title = 'MAC与批次报表' + headers = ['MAC地址', '批次号', '时间'] + c.execute('SELECT mac, batch, ts FROM mac_batches ORDER BY id DESC LIMIT 200') + elif data_type == 'repairs': + title = '返修记录报表' + headers = ['返修数量', '备注', '时间'] + c.execute('SELECT qty, note, ts FROM repairs ORDER BY id DESC LIMIT 200') + elif data_type == 'defects': + title = '不良明细报表' + headers = ['MAC地址', '批次号', '时间'] + c.execute('SELECT mac, batch, ts FROM defects ORDER BY id DESC LIMIT 200') + elif data_type == 'shipments': + title = '发货记录报表' + headers = ['日期', '数量', '收货方', '时间'] + c.execute('SELECT date, qty, receiver, ts FROM shipments ORDER BY id DESC LIMIT 200') + elif data_type == 'devices': + title = '设备状态报表' + headers = ['设备名称', '状态'] + c.execute('SELECT name, status FROM devices ORDER BY id DESC LIMIT 200') + elif data_type == 'personnel': + title = '人员信息报表' + headers = ['姓名', '角色'] + c.execute('SELECT name, role FROM personnel ORDER BY id DESC LIMIT 200') + elif data_type == 'qa': + title = '质检报告' + headers = ['标题', '日期'] + c.execute('SELECT title, date FROM qa ORDER BY id DESC LIMIT 200') + elif data_type == 'production': + title = '生产时间记录' + headers = ['批次', '时长'] + c.execute('SELECT batch, duration FROM production ORDER BY id DESC LIMIT 200') + else: + conn.close() + return jsonify({'error': 'invalid type'}), 400 + + # 添加标题 + elements.append(Paragraph(title, title_style)) + elements.append(Spacer(1, 0.5*cm)) + + # 获取数据 + rows = c.fetchall() + conn.close() + + if len(rows) == 0: + # 没有数据时的提示 + no_data_style = ParagraphStyle( + 'NoData', + parent=styles['Normal'], + fontName=font_name, + fontSize=12, + textColor=colors.grey, + alignment=TA_CENTER + ) + elements.append(Paragraph('暂无数据', no_data_style)) + else: + # 构建表格数据 + table_data = [headers] + for row in rows: + table_data.append([str(val) if val is not None else '' for val in row]) + + # 创建表格 + table = Table(table_data, repeatRows=1) + table.setStyle(TableStyle([ + # 表头样式 + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F8CFF')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('FONTNAME', (0, 0), (-1, 0), font_name), + ('FONTSIZE', (0, 0), (-1, 0), 10), + ('BOTTOMPADDING', (0, 0), (-1, 0), 8), + ('TOPPADDING', (0, 0), (-1, 0), 8), + + # 数据行样式 + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), + ('FONTNAME', (0, 1), (-1, -1), font_name), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#F5F7FA')]), + ('TOPPADDING', (0, 1), (-1, -1), 5), + ('BOTTOMPADDING', (0, 1), (-1, -1), 5), + ])) + + elements.append(table) + + # 添加页脚信息 + elements.append(Spacer(1, 0.5*cm)) + footer_style = ParagraphStyle( + 'Footer', + parent=styles['Normal'], + fontName=font_name, + fontSize=8, + textColor=colors.grey, + alignment=TA_CENTER + ) + footer_text = f'导出时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | 共 {len(rows)} 条记录' + elements.append(Paragraph(footer_text, footer_style)) + + # 生成PDF + doc.build(elements) + buffer.seek(0) + + log('export_pdf', data_type) + + # 返回文件 + from flask import send_file + filename = f'{title}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf' + return send_file( + buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=filename + ) + except Exception as e: + log('export_pdf_error', str(e)) + return jsonify({'error': f'PDF导出失败: {str(e)}'}), 500 + + +# lists +@app.get('/api/list/mac') +@require_login +def list_mac(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT mac, batch, ts FROM mac_batches ORDER BY id DESC LIMIT 200') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + +@app.get('/api/list/stats') +@require_login +def list_stats(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT good, bad, fpy_good, platform, ts FROM stats ORDER BY id DESC LIMIT 200') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + +@app.get('/api/list/repairs') +@require_login +def list_repairs(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT qty, note, ts FROM repairs ORDER BY id DESC LIMIT 200') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + +@app.get('/api/list/defects') +@require_login +def list_defects(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT mac, batch, ts FROM defects ORDER BY id DESC LIMIT 200') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + +@app.get('/api/list/shipments') +@require_login +def list_shipments(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT date, qty, receiver, ts FROM shipments ORDER BY id DESC LIMIT 200') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +# admin management +@app.get('/api/admin/users') +@require_login +@require_any_role('superadmin') +def list_users(): + conn = get_db() + c = conn.cursor() + c.execute('SELECT username, role FROM users ORDER BY id ASC') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.post('/api/admin/reset-password') +@require_login +@require_any_role('superadmin') +def reset_password(): + data = request.get_json() or {} + username = data.get('username') + new_password = data.get('new_password') + if not username or not new_password: + return jsonify({'error': 'invalid payload'}), 400 + conn = get_db() + c = conn.cursor() + c.execute('SELECT id FROM users WHERE username=?', (username,)) + row = c.fetchone() + if not row: + conn.close() + return jsonify({'error': 'user not found'}), 404 + c.execute('UPDATE users SET password_hash=? WHERE id=?', (generate_password_hash(new_password), row['id'])) + conn.commit() + conn.close() + log('reset_password', username) + return jsonify({'ok': True}) + + +@app.post('/api/admin/change-password') +@require_login +@require_any_role('superadmin') +def change_password(): + data = request.get_json() or {} + username = data.get('username') + new_password = data.get('new_password') + if not username or not new_password: + return jsonify({'error': 'invalid payload'}), 400 + conn = get_db() + c = conn.cursor() + c.execute('SELECT id FROM users WHERE username=?', (username,)) + row = c.fetchone() + if not row: + conn.close() + return jsonify({'error': 'user not found'}), 404 + c.execute('UPDATE users SET password_hash=? WHERE id=?', (generate_password_hash(new_password), row['id'])) + conn.commit() + conn.close() + log('change_password', username) + return jsonify({'ok': True}) + + +@app.post('/api/admin/add-user') +@require_login +@require_any_role('superadmin') +def add_user(): + """添加新用户""" + data = request.get_json() or {} + username = (data.get('username') or '').strip() + password = data.get('password') + role = (data.get('role') or 'admin').strip() + + if not username or not password: + return jsonify({'error': '用户名和密码不能为空'}), 400 + + if role not in ['admin', 'superadmin']: + return jsonify({'error': '角色必须是 admin 或 superadmin'}), 400 + + conn = get_db() + c = conn.cursor() + + # 检查用户名是否已存在 + c.execute('SELECT id FROM users WHERE username=?', (username,)) + if c.fetchone(): + conn.close() + return jsonify({'error': '用户名已存在'}), 400 + + # 创建新用户 + try: + c.execute('INSERT INTO users(username, password_hash, role) VALUES(?,?,?)', + (username, generate_password_hash(password), role)) + conn.commit() + conn.close() + + log('add_user', f'username={username}, role={role}') + return jsonify({'ok': True, 'message': f'用户 {username} 创建成功'}) + except Exception as e: + conn.close() + return jsonify({'error': f'创建用户失败:{str(e)}'}), 500 + + +@app.post('/api/admin/clear') +@require_login +@require_any_role('superadmin') +def clear_module(): + data = request.get_json() or {} + module = data.get('module') + tables = { + 'mac': 'mac_batches', + 'stats': 'stats', + 'defects': 'defects', + 'shipments': 'shipments', + 'devices': 'devices', + 'environment': 'environment', + 'personnel': 'personnel', + 'qa': 'qa', + 'production': 'production' + } + table = tables.get(module) + if not table: + return jsonify({'error': 'invalid module'}), 400 + conn = get_db() + c = conn.cursor() + c.execute(f'DELETE FROM {table}') + conn.commit() + conn.close() + log('clear_module', module) + return jsonify({'ok': True}) + + +# notifications +@app.get('/api/notifications') +@require_login +@require_any_role('superadmin') +def get_notifications(): + """获取当前用户的通知列表""" + user_id = session.get('user_id') + conn = get_db() + c = conn.cursor() + c.execute('SELECT id, username, action, detail, ts, read FROM notifications WHERE user_id=? ORDER BY id DESC LIMIT 100', (user_id,)) + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.get('/api/notifications/unread-count') +@require_login +@require_any_role('superadmin') +def get_unread_count(): + """获取未读通知数量""" + user_id = session.get('user_id') + conn = get_db() + c = conn.cursor() + c.execute('SELECT COUNT(*) as count FROM notifications WHERE user_id=? AND read=0', (user_id,)) + row = c.fetchone() + conn.close() + return jsonify({'count': row['count'] if row else 0}) + + +@app.post('/api/notifications/mark-read') +@require_login +@require_any_role('superadmin') +def mark_notification_read(): + """标记通知为已读""" + data = request.get_json() or {} + notification_id = data.get('id') + + if not notification_id: + return jsonify({'error': 'invalid id'}), 400 + + user_id = session.get('user_id') + conn = get_db() + c = conn.cursor() + c.execute('UPDATE notifications SET read=1 WHERE id=? AND user_id=?', (notification_id, user_id)) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +@app.post('/api/notifications/mark-all-read') +@require_login +@require_any_role('superadmin') +def mark_all_notifications_read(): + """标记所有通知为已读""" + user_id = session.get('user_id') + conn = get_db() + c = conn.cursor() + c.execute('UPDATE notifications SET read=1 WHERE user_id=?', (user_id,)) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +@app.post('/api/notifications/delete-read') +@require_login +@require_any_role('superadmin') +def delete_read_notifications(): + """删除所有已读通知""" + user_id = session.get('user_id') + conn = get_db() + c = conn.cursor() + c.execute('DELETE FROM notifications WHERE user_id=? AND read=1', (user_id,)) + deleted_count = c.rowcount + conn.commit() + conn.close() + return jsonify({'ok': True, 'count': deleted_count}) + + +@app.errorhandler(404) +def not_found(_): + return jsonify({'error': 'not found'}), 404 + + +@app.route('/api/validate/mac-file', methods=['POST']) +@require_login +@require_any_role('admin','superadmin') +def validate_mac_file(): + """验证Excel文件格式是否符合要求""" + f = request.files.get('file') + if not f: + return jsonify({'error': 'no file'}), 400 + + name = secure_filename(f.filename or '') + ext = (name.split('.')[-1] or '').lower() + + if ext not in ['csv', 'xlsx', 'xls']: + return jsonify({'valid': False, 'message': '文件格式不支持,请上传CSV或Excel文件'}), 200 + + try: + if ext == 'csv': + text = f.stream.read().decode('utf-8', errors='ignore') + lines = [l.strip() for l in text.splitlines() if l.strip()] + if not lines: + return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 200 + + # 检查第一行(表头) + header = [h.strip() for h in lines[0].split(',')] + if len(header) != 2: + return jsonify({'valid': False, 'message': f'文件应该只包含2列数据,当前有{len(header)}列'}), 200 + + # 记录表头用于调试 + log('validate_mac_file_csv', f'headers: {header}') + + # 更灵活的表头检查(不区分大小写) + header_lower = [h.lower() for h in header] + has_mac = any('mac' in h and 'sn' not in h for h in header_lower) + has_sn_mac = any('sn_mac' in h or 'sn-mac' in h for h in header_lower) + has_batch = any('批次' in h or 'batch' in h for h in header_lower) + + if not (has_mac or has_sn_mac): + return jsonify({'valid': False, 'message': f'缺少必需的列:MAC 或 SN_MAC(当前列:{", ".join(header)})'}), 200 + if not has_batch: + return jsonify({'valid': False, 'message': f'缺少必需的列:批次号(当前列:{", ".join(header)})'}), 200 + + data_rows = len(lines) - 1 + mac_col = 'MAC' if has_mac else 'SN_MAC' + return jsonify({'valid': True, 'message': f'文件格式正确,包含列:{mac_col} 和 批次号,共{data_rows}行数据'}), 200 + else: + import openpyxl + wb = openpyxl.load_workbook(f) + ws = wb.active + + if ws.max_row < 2: + wb.close() + return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 200 + + if ws.max_column != 2: + wb.close() + return jsonify({'valid': False, 'message': f'文件应该只包含2列数据,当前有{ws.max_column}列'}), 200 + + # 检查表头 + header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] + header = [str(h).strip() if h else '' for h in header_row] + + # 记录表头用于调试 + log('validate_mac_file', f'headers: {header}') + + # 更灵活的表头检查(不区分大小写) + header_lower = [h.lower() for h in header] + has_mac = any('mac' in h and 'sn' not in h for h in header_lower) + has_sn_mac = any('sn_mac' in h or 'sn-mac' in h for h in header_lower) + has_batch = any('批次' in h or 'batch' in h for h in header_lower) + + if not (has_mac or has_sn_mac): + wb.close() + return jsonify({'valid': False, 'message': f'缺少必需的列:MAC 或 SN_MAC(当前列:{", ".join(header)})'}), 200 + if not has_batch: + wb.close() + return jsonify({'valid': False, 'message': f'缺少必需的列:批次号(当前列:{", ".join(header)})'}), 200 + + data_rows = ws.max_row - 1 + mac_col = 'MAC' if has_mac else 'SN_MAC' + wb.close() + return jsonify({'valid': True, 'message': f'文件格式正确,包含列:{mac_col} 和 批次号,共{data_rows}行数据'}), 200 + + except Exception as e: + return jsonify({'valid': False, 'message': f'读取文件失败:{str(e)}'}), 200 + + +@app.post('/api/upload/mac-file') +@require_login +@require_any_role('admin','superadmin') +def upload_mac_file(): + import subprocess + import tempfile + + f = request.files.get('file') + upload_type = request.form.get('type', 'pdd') # pdd, yt, or tx + + if not f: + return jsonify({'error': 'no file'}), 400 + + if upload_type not in ['pdd', 'yt', 'tx']: + return jsonify({'error': 'invalid type'}), 400 + + # 保存上传的文件到临时位置 + name = secure_filename(f.filename or 'upload.xlsx') + temp_dir = '/home/hyx/work/batch_import_xlsx' + os.makedirs(temp_dir, exist_ok=True) + + # 根据类型确定文件名 + if upload_type == 'yt': + temp_path = os.path.join(temp_dir, 'sn_test_yt.xlsx') + elif upload_type == 'pdd': + temp_path = os.path.join(temp_dir, 'sn_test_pdd.xlsx') + else: + temp_path = os.path.join(temp_dir, 'sn_test_tx.xlsx') + + f.save(temp_path) + + # 调用batch_import.py脚本 + script_path = '/home/hyx/work/生产管理系统/batch_import.py' + python_path = '/home/hyx/work/.venv/bin/python' + try: + result = subprocess.run( + [python_path, script_path, upload_type], + capture_output=True, + text=True, + timeout=300 # 5分钟超时 + ) + + output = result.stdout + result.stderr + success = result.returncode == 0 + + log('upload_mac_file', f"type={upload_type}, success={success}") + if success: + notify_superadmin('批量上传MAC文件', f"类型: {upload_type}") + + return jsonify({ + 'ok': success, + 'output': output, + 'returncode': result.returncode + }) + except subprocess.TimeoutExpired: + return jsonify({'error': '上传超时', 'output': '处理时间超过5分钟'}), 500 + except Exception as e: + return jsonify({'error': str(e), 'output': ''}), 500 + +@app.post('/api/upload/defects-file') +@require_login +@require_any_role('admin','superadmin') +def upload_defects_file(): + f = request.files.get('file') + if not f: + return jsonify({'error': 'no file'}), 400 + name = secure_filename(f.filename or '') + ext = (name.split('.')[-1] or '').lower() + rows = [] + if ext == 'csv': + text = f.stream.read().decode('utf-8', errors='ignore') + for l in text.splitlines(): + parts = [p.strip() for p in l.split(',')] + if len(parts) >= 2: + rows.append({'mac': parts[0], 'batch': parts[1]}) + else: + try: + import openpyxl + wb = openpyxl.load_workbook(f) + ws = wb.active + for r in ws.iter_rows(values_only=True): + mac = str(r[0]).strip() if r and r[0] else None + batch = str(r[1]).strip() if r and len(r) > 1 and r[1] else None + if mac and batch: + rows.append({'mac': mac, 'batch': batch}) + except Exception: + return jsonify({'error': 'parse error'}), 400 + conn = get_db() + c = conn.cursor() + now = datetime.utcnow().isoformat() + for r in rows: + c.execute('INSERT INTO defects(mac, batch, ts) VALUES(?,?,?)', (r['mac'], r['batch'], now)) + conn.commit() + conn.close() + log('upload_defects_file', f"count={len(rows)}") + notify_superadmin('批量上传不良明细文件', f"上传了 {len(rows)} 条记录") + return jsonify({'ok': True, 'count': len(rows)}) + + +@app.route('/api/validate/shipments-file', methods=['POST']) +@require_login +@require_any_role('admin','superadmin') +def validate_shipments_file(): + """验证发货记录Excel文件格式""" + f = request.files.get('file') + if not f: + return jsonify({'error': 'no file'}), 400 + + name = secure_filename(f.filename or '') + ext = (name.split('.')[-1] or '').lower() + + if ext not in ['csv', 'xlsx', 'xls']: + return jsonify({'valid': False, 'message': '文件格式不支持,请上传CSV或Excel文件'}), 200 + + try: + if ext == 'csv': + text = f.stream.read().decode('utf-8', errors='ignore') + lines = [l.strip() for l in text.splitlines() if l.strip()] + if not lines: + return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 200 + + header = [h.strip() for h in lines[0].split(',')] + header_lower = [h.lower() for h in header] + + # 检查必需的列 + has_date = any('出货日期' in h or '发货日期' in h or 'date' in hl for h, hl in zip(header, header_lower)) + has_box = any('箱号' in h or 'box' in hl for h, hl in zip(header, header_lower)) + + if not has_date: + return jsonify({'valid': False, 'message': '缺少必需的列:出货日期'}), 200 + if not has_box: + return jsonify({'valid': False, 'message': '缺少必需的列:箱号'}), 200 + + # 检查SN列(SN1-SN20) + sn_cols = [h for h in header if h.startswith('SN') and h[2:].isdigit()] + if not sn_cols: + return jsonify({'valid': False, 'message': '缺少SN列(SN1, SN2, ... SN20)'}), 200 + + data_rows = len(lines) - 1 + return jsonify({'valid': True, 'message': f'文件格式正确,包含{len(sn_cols)}个SN列,共{data_rows}行数据'}), 200 + else: + import openpyxl + wb = openpyxl.load_workbook(f) + ws = wb.active + + if ws.max_row < 2: + wb.close() + return jsonify({'valid': False, 'message': '文件为空,没有数据'}), 200 + + # 检查表头 + header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] + header = [str(h).strip() if h else '' for h in header_row] + header_lower = [h.lower() for h in header] + + # 检查必需的列 + has_date = any('出货日期' in h or '发货日期' in h or 'date' in hl for h, hl in zip(header, header_lower)) + has_box = any('箱号' in h or 'box' in hl for h, hl in zip(header, header_lower)) + + if not has_date: + wb.close() + return jsonify({'valid': False, 'message': '缺少必需的列:出货日期'}), 200 + if not has_box: + wb.close() + return jsonify({'valid': False, 'message': '缺少必需的列:箱号'}), 200 + + # 检查SN列 + sn_cols = [h for h in header if h.startswith('SN') and h[2:].isdigit()] + if not sn_cols: + wb.close() + return jsonify({'valid': False, 'message': '缺少SN列(SN1, SN2, ... SN20)'}), 200 + + data_rows = ws.max_row - 1 + wb.close() + return jsonify({'valid': True, 'message': f'文件格式正确,包含{len(sn_cols)}个SN列,共{data_rows}行数据'}), 200 + + except Exception as e: + return jsonify({'valid': False, 'message': f'读取文件失败:{str(e)}'}), 200 + + +@app.get('/api/shipments/query-by-sn') +@require_login +def query_shipment_by_sn(): + """通过 SN/MAC 号查询出货信息""" + sn = request.args.get('sn', '').strip() + + if not sn: + return jsonify({'error': '请提供 SN/MAC 号'}), 400 + + try: + r = get_redis() + redis_key = 'shipment_sn_mapping' + + # 从 Redis Hash 中查询 + result = r.hget(redis_key, sn) + + if result: + # 解析 JSON 数据 + shipment_info = json.loads(result) + platform = shipment_info.get('platform', 'pdd') # 默认拼多多 + platform_name = {'pdd': '拼多多', 'yt': '圆通', 'tx': '兔喜'}.get(platform, platform) + return jsonify({ + 'found': True, + 'sn': sn, + 'date': shipment_info.get('date'), + 'box': shipment_info.get('box'), + 'platform': platform, + 'platform_name': platform_name, + 'ts': shipment_info.get('ts') + }) + else: + return jsonify({ + 'found': False, + 'sn': sn, + 'message': '未找到该 SN 的出货记录' + }) + + except Exception as e: + log('query_shipment_error', str(e)) + return jsonify({'error': f'查询失败:{str(e)}'}), 500 + + +@app.post('/api/shipments/update-platform') +@require_login +@require_any_role('superadmin') +def update_shipments_platform(): + """批量更新Redis中发货记录的机种字段""" + try: + r = get_redis() + redis_key = 'shipment_sn_mapping' + + # 获取所有记录 + all_data = r.hgetall(redis_key) + updated_count = 0 + + pipe = r.pipeline() + for sn, value in all_data.items(): + try: + info = json.loads(value) + # 如果没有platform字段,添加为pdd + if 'platform' not in info: + info['platform'] = 'pdd' + pipe.hset(redis_key, sn, json.dumps(info, ensure_ascii=False)) + updated_count += 1 + except Exception: + continue + + pipe.execute() + + log('update_shipments_platform', f'updated {updated_count} records') + return jsonify({ + 'ok': True, + 'message': f'已更新 {updated_count} 条记录为拼多多', + 'updated': updated_count + }) + except Exception as e: + log('update_shipments_platform_error', str(e)) + return jsonify({'error': f'更新失败: {str(e)}'}), 500 + + +@app.get('/api/shipments/redis-stats') +@require_login +def shipments_redis_stats(): + """获取 Redis 中发货记录的统计信息""" + try: + r = get_redis() + redis_key = 'shipment_sn_mapping' + + count = r.hlen(redis_key) + + return jsonify({ + 'key': redis_key, + 'count': count, + 'exists': r.exists(redis_key) > 0 + }) + except Exception as e: + log('shipments_redis_stats_error', str(e)) + return jsonify({'error': f'获取统计失败:{str(e)}'}), 500 + + +@app.post('/api/shipments/clear-redis') +@require_login +@require_any_role('admin','superadmin') +def clear_shipments_redis(): + """清空 Redis 中的发货记录数据""" + try: + r = get_redis() + redis_key = 'shipment_sn_mapping' + + # 获取删除前的数量 + count_before = r.hlen(redis_key) + + # 删除整个 Hash + r.delete(redis_key) + + log('clear_shipments_redis', f'cleared {count_before} records') + + return jsonify({ + 'ok': True, + 'message': f'已清空 {count_before} 条发货记录', + 'count': count_before + }) + except Exception as e: + log('clear_shipments_redis_error', str(e)) + return jsonify({'error': f'清空失败:{str(e)}'}), 500 + + +@app.route('/api/upload/shipments-file', methods=['POST']) +@require_login +@require_any_role('admin','superadmin') +def upload_shipments_file(): + """上传发货记录Excel文件""" + f = request.files.get('file') + platform = request.form.get('platform') # 获取机种参数 + + if not f: + return jsonify({'error': '请选择文件'}), 400 + + if not platform or platform not in ['pdd', 'yt', 'tx']: + return jsonify({'error': '请选择机种(拼多多/圆通/兔喜)'}), 400 + + name = secure_filename(f.filename or '') + ext = (name.split('.')[-1] or '').lower() + + if ext not in ['csv', 'xlsx', 'xls']: + return jsonify({'error': '文件格式不支持'}), 400 + + try: + rows = [] + if ext == 'csv': + text = f.stream.read().decode('utf-8', errors='ignore') + lines = [l.strip() for l in text.splitlines() if l.strip()] + if len(lines) < 2: + return jsonify({'error': '文件为空'}), 400 + + header = [h.strip() for h in lines[0].split(',')] + header_lower = [h.lower() for h in header] + + # 找到列索引 + date_idx = next((i for i, h in enumerate(header) if '出货日期' in h or '发货日期' in h or 'date' in header_lower[i]), None) + box_idx = next((i for i, h in enumerate(header) if '箱号' in h or 'box' in header_lower[i]), None) + + if date_idx is None or box_idx is None: + return jsonify({'error': '缺少必需的列'}), 400 + + # 找到所有SN列(按数字排序) + sn_cols = [(i, h) for i, h in enumerate(header) if h.startswith('SN') and h[2:].isdigit()] + sn_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序 + + # 记录上一个有效的日期(用于处理合并单元格) + last_valid_date = None + + for line in lines[1:]: + parts = [p.strip() for p in line.split(',')] + if len(parts) <= max(date_idx, box_idx): + continue + + # 处理日期(合并单元格时可能为空) + current_date = parts[date_idx] if date_idx < len(parts) and parts[date_idx] else '' + + # 如果当前行日期为空,使用上一个有效日期 + if current_date: + last_valid_date = current_date + date = current_date + else: + date = last_valid_date + + # 处理箱号 + box = parts[box_idx] if box_idx < len(parts) and parts[box_idx] else '' + + # 如果没有日期或箱号,跳过这行 + if not date or not box: + continue + + # 收集所有SN(横向 20 个) + sns = [] + for idx, _ in sn_indices: + if idx < len(parts) and parts[idx]: + sns.append(parts[idx]) + + # 只有当有 SN 数据时才添加记录 + if sns: + rows.append({ + 'date': date, + 'box': box, + 'sns': sns, + 'qty': len(sns) + }) + else: + import openpyxl + wb = openpyxl.load_workbook(f) + ws = wb.active + + if ws.max_row < 2: + wb.close() + return jsonify({'error': '文件为空'}), 400 + + # 读取表头 + header_row = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0] + header = [str(h).strip() if h else '' for h in header_row] + header_lower = [h.lower() for h in header] + + # 找到列索引 + date_idx = next((i for i, h in enumerate(header) if '出货日期' in h or '发货日期' in h or 'date' in header_lower[i]), None) + box_idx = next((i for i, h in enumerate(header) if '箱号' in h or 'box' in header_lower[i]), None) + + if date_idx is None or box_idx is None: + wb.close() + return jsonify({'error': '缺少必需的列'}), 400 + + # 找到所有SN列(按数字排序) + sn_cols = [(i, h) for i, h in enumerate(header) if h.startswith('SN') and h[2:].isdigit()] + sn_indices = sorted(sn_cols, key=lambda x: int(x[1][2:])) # 按 SN 后面的数字排序 + + # 记录上一个有效的日期(用于处理合并单元格) + last_valid_date = None + + # 读取数据行 + for row in ws.iter_rows(min_row=2, values_only=True): + # 处理日期(合并单元格时可能为 None) + date_value = row[date_idx] if date_idx < len(row) else None + current_date = None + + if date_value: + # 如果是 datetime.datetime 或 datetime.date 对象 + if hasattr(date_value, 'strftime'): + current_date = date_value.strftime('%Y-%m-%d') + # 如果是数字(Excel 日期序列号) + elif isinstance(date_value, (int, float)): + try: + # Excel 日期从 1900-01-01 开始计数 + from datetime import datetime as dt, timedelta + # Excel 的 bug:1900 不是闰年但 Excel 认为是,所以需要减1 + excel_epoch = dt(1899, 12, 30) + date_obj = excel_epoch + timedelta(days=float(date_value)) + current_date = date_obj.strftime('%Y-%m-%d') + except Exception: + current_date = str(date_value).strip() + else: + current_date = str(date_value).strip() + if current_date == 'None': + current_date = None + + # 如果当前行日期为空,使用上一个有效日期 + if current_date: + last_valid_date = current_date + date = current_date + else: + date = last_valid_date + + # 处理箱号 + box = str(row[box_idx]).strip() if box_idx < len(row) and row[box_idx] else '' + + # 如果没有日期或箱号,跳过这行 + if not date or not box or box == 'None': + continue + + # 收集所有SN(横向 20 个) + sns = [] + for idx, _ in sn_indices: + if idx < len(row) and row[idx]: + sn_value = str(row[idx]).strip() + if sn_value and sn_value != 'None': + sns.append(sn_value) + + # 只有当有 SN 数据时才添加记录 + if sns: + rows.append({ + 'date': date, + 'box': box, + 'sns': sns, + 'qty': len(sns) + }) + + wb.close() + + # 保存到 SQLite 数据库 + conn = get_db() + c = conn.cursor() + # 使用北京时间(UTC+8) + from datetime import timezone, timedelta + beijing_tz = timezone(timedelta(hours=8)) + now = datetime.now(beijing_tz).strftime('%Y-%m-%d %H:%M:%S') + + total_qty = 0 + for r in rows: + receiver_info = f"箱号:{r['box']}" + c.execute('INSERT INTO shipments(date, qty, receiver, ts) VALUES(?,?,?,?)', + (r['date'], r['qty'], receiver_info, now)) + total_qty += r['qty'] + + conn.commit() + conn.close() + + # 保存到 Redis - 使用 Hash 结构存储 MAC -> 出货信息的映射 + try: + r = get_redis() + redis_key = 'shipment_sn_mapping' # Redis Hash key + + # 使用 pipeline 批量写入,提高性能 + pipe = r.pipeline() + redis_count = 0 + + for row_data in rows: + date = row_data['date'] + box = row_data['box'] + sns = row_data['sns'] + + # 为每个 SN/MAC 创建映射记录 + for sn in sns: + if sn: # 确保 SN 不为空 + # 存储格式: MAC -> JSON(date, box, platform, timestamp) + shipment_info = json.dumps({ + 'date': date, + 'box': box, + 'platform': platform, + 'ts': now + }, ensure_ascii=False) + pipe.hset(redis_key, sn, shipment_info) + redis_count += 1 + + # 执行批量写入 + pipe.execute() + + log('upload_shipments_redis', f"redis_key={redis_key}, sn_count={redis_count}") + + except Exception as redis_error: + # Redis 写入失败不影响主流程,只记录日志 + log('upload_shipments_redis_error', str(redis_error)) + + log('upload_shipments_file', f"boxes={len(rows)}, total_qty={total_qty}") + notify_superadmin('批量上传发货记录文件', f"箱数: {len(rows)}, 总数量: {total_qty}") + return jsonify({'ok': True, 'count': len(rows), 'total_qty': total_qty}) + + except Exception as e: + log('upload_shipments_file_error', str(e)) + return jsonify({'error': f'处理文件失败:{str(e)}'}), 500 + + +init_db() + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=int(os.environ.get('PORT', '5000'))) diff --git a/shipments_merge_cells_guide.md b/shipments_merge_cells_guide.md new file mode 100644 index 0000000..42c69f9 --- /dev/null +++ b/shipments_merge_cells_guide.md @@ -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 查询详细信息 diff --git a/shipments_redis_structure.md b/shipments_redis_structure.md new file mode 100644 index 0000000..de7e56b --- /dev/null +++ b/shipments_redis_structure.md @@ -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. **索引优化**: 如需按日期或箱号查询,可建立额外的索引结构 diff --git a/shipments_template_example.md b/shipments_template_example.md new file mode 100644 index 0000000..6088603 --- /dev/null +++ b/shipments_template_example.md @@ -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个数)、箱号信息 diff --git a/test-theme.html b/test-theme.html new file mode 100644 index 0000000..a31c76b --- /dev/null +++ b/test-theme.html @@ -0,0 +1,71 @@ + + + + +主题测试 + + + + +
    + + +
    + +
    +

    上传日志测试

    +
    +
    上传日志
    +
    +[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] 完成!
    +    
    +
    +
    + +
    +

    日期选择器测试

    + +
    + +
    +

    卡片测试

    +
    +
    测试卡片
    +

    这是一个测试卡片,用于验证主题颜色是否正确。

    +
      +
    • 项目1标签
    • +
    • 项目2标签
    • +
    +
    +
    + + + + diff --git a/validate_excel.py b/validate_excel.py new file mode 100644 index 0000000..a9b3a02 --- /dev/null +++ b/validate_excel.py @@ -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 ") + 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) diff --git a/用户.svg b/用户.svg new file mode 100644 index 0000000..6834ce2 --- /dev/null +++ b/用户.svg @@ -0,0 +1 @@ + \ No newline at end of file