diff --git a/frontend/js/api.js b/frontend/js/api.js index 0bc580f..47e3395 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -133,6 +133,23 @@ const API = (() => { }, uploadStats: data => request('/upload/stats', { method: 'POST', body: JSON.stringify(data) }), uploadRepairs: data => request('/upload/repairs', { method: 'POST', body: JSON.stringify(data) }), + uploadRepairRecord: formData => uploadFile('/upload/repair-record', formData), + listRepairRecords: (params={}) => request('/list/repair-records' + (params.sn ? `?sn=${encodeURIComponent(params.sn)}` : params.limit ? `?limit=${params.limit}` : '')), + getRepairRecord: id => request(`/repair-record/${id}`), + updateRepairRecord: (id, data) => request(`/repair-record/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteRepairRecord: id => request(`/repair-record/${id}`, { method: 'DELETE' }), + exportRepairRecords: ids => { + // 使用fetch直接下载文件 + return fetch('/api/export/repair-records', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ ids }) + }).then(res => { + if(!res.ok) return res.json().then(d => Promise.reject(d)); + return res.blob(); + }); + }, uploadDefects: data => request('/upload/defects', { method: 'POST', body: JSON.stringify(data) }), uploadDefectsFile: file => { const fd = new FormData(); diff --git a/frontend/js/components/upload.js b/frontend/js/components/upload.js index 9a8d072..763f861 100644 --- a/frontend/js/components/upload.js +++ b/frontend/js/components/upload.js @@ -134,17 +134,128 @@ const Upload = (() => { `); } + // 不良原因选项 + const defectReasonOptions = [ + '无不良', + '外观不良', + '功能异常', + '连接问题', + '电源故障', + '软件问题', + '物理损坏', + '其他' + ]; + + // 机种选项 + const machineTypeOptions = [ + '拼多多', + '圆通', + '兔喜', + '美团', + '大润发', + '标准版' + ]; + async function renderRepairs(){ - return section('返修记录',` - ${numberInput('repair-qty','返修完成数量')} - ${textarea('repair-note','备注(可选)','例如:批次号、问题描述等')} -
-
-
- 最新记录 - + return section('返修记录上传',` +
+ + + +
+ +
+ + + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ +
+ +
+ + + + + +
+
点击或拖拽图片到此处上传
+
支持 JPG/PNG/GIF,单张最大10MB,最多10张
-
    +
    + +
    + +
    + + +
    + + + +
    +
    + 历史记录 +
    + + + + +
    +
    +
    +
    +
    + + +
    +
    +
      `); } @@ -695,57 +806,714 @@ const Upload = (() => { async function bindRepairsEvents(){ const btn = document.getElementById('repairs-upload'); + const clearBtn = document.getElementById('repairs-clear'); const showAllBtn = document.getElementById('repairs-show-all'); + const snInput = document.getElementById('repair-sn'); + const machineTypeSelect = document.getElementById('repair-machine-type'); + const defectSelect = document.getElementById('repair-defect-reason'); + const defectOtherInput = document.getElementById('repair-defect-reason-other'); + const methodInput = document.getElementById('repair-method'); + const methodGroup = document.getElementById('repair-method-group'); + const imageInput = document.getElementById('repair-images'); + const dropzone = document.getElementById('repair-image-dropzone'); + const previewContainer = document.getElementById('repair-image-preview'); + const snError = document.getElementById('repair-sn-error'); + const machineTypeError = document.getElementById('repair-machine-type-error'); + const defectError = document.getElementById('repair-defect-error'); + const methodError = document.getElementById('repair-method-error'); + const methodRequired = document.getElementById('repair-method-required'); + const imageError = document.getElementById('repair-image-error'); + const uploadText = document.getElementById('repairs-upload-text'); + const uploadLoading = document.getElementById('repairs-upload-loading'); + + // 存储待上传的图片文件 + let pendingImages = []; + + // 显示/隐藏"其他"原因输入框,并根据不良原因决定维修方法是否必填 + addListener(defectSelect, 'change', () => { + if(defectSelect.value === '其他') { + defectOtherInput.style.display = 'block'; + defectOtherInput.focus(); + } else { + defectOtherInput.style.display = 'none'; + defectOtherInput.value = ''; + } + // 清除错误提示 + defectError.style.display = 'none'; + methodError.style.display = 'none'; + + // 条件性显示维修方法:无不良时可选,其他原因时必填 + if(defectSelect.value) { + methodGroup.style.opacity = '1'; + methodInput.disabled = false; + + if(defectSelect.value === '无不良') { + // 无不良时维修方法可选 + methodRequired.style.display = 'none'; + methodInput.placeholder = '请描述维修方法(可选)'; + } else { + // 其他原因时维修方法必填 + methodRequired.style.display = 'inline'; + methodInput.placeholder = '请描述维修方法(必填)'; + } + } else { + methodGroup.style.opacity = '0.5'; + methodInput.disabled = true; + methodRequired.style.display = 'none'; + methodInput.placeholder = '请描述维修方法'; + } + }); + + // 初始化维修方法状态 + methodGroup.style.opacity = '0.5'; + methodInput.disabled = true; + + // SN输入验证 + addListener(snInput, 'input', () => { + snError.style.display = 'none'; + }); + + // 图片上传处理 + const validateAndAddImages = (files) => { + const validTypes = ['image/jpeg', 'image/png', 'image/gif']; + const maxSize = 10 * 1024 * 1024; // 10MB + const maxCount = 10; + + for(const file of files) { + if(pendingImages.length >= maxCount) { + imageError.textContent = `最多上传${maxCount}张图片`; + imageError.style.display = 'block'; + return; + } + + if(!validTypes.includes(file.type)) { + imageError.textContent = `不支持的格式: ${file.name},仅支持JPG/PNG/GIF`; + imageError.style.display = 'block'; + continue; + } + + if(file.size > maxSize) { + imageError.textContent = `图片 ${file.name} 超过10MB限制`; + imageError.style.display = 'block'; + continue; + } + + // 检查是否已存在同名文件 + if(pendingImages.some(f => f.name === file.name && f.size === file.size)) { + continue; + } + + pendingImages.push(file); + imageError.style.display = 'none'; + } + + renderImagePreviews(); + }; + + const renderImagePreviews = () => { + previewContainer.innerHTML = pendingImages.map((file, index) => { + const url = URL.createObjectURL(file); + return ` +
      + + +
      + `; + }).join(''); + + // 绑定删除按钮事件 + previewContainer.querySelectorAll('.remove-image-btn').forEach(btn => { + btn.onclick = (e) => { + e.stopPropagation(); + const index = parseInt(btn.dataset.index); + pendingImages.splice(index, 1); + renderImagePreviews(); + }; + }); + }; + + // 点击上传区域 + addListener(dropzone, 'click', () => { + imageInput.click(); + }); + + // 文件选择 + addListener(imageInput, 'change', () => { + if(imageInput.files.length > 0) { + validateAndAddImages(Array.from(imageInput.files)); + imageInput.value = ''; // 清空以便重复选择同一文件 + } + }); + + // 拖拽上传 + addListener(dropzone, 'dragover', (e) => { + e.preventDefault(); + dropzone.classList.add('dragover'); + }); + + addListener(dropzone, 'dragleave', () => { + dropzone.classList.remove('dragover'); + }); + + addListener(dropzone, 'drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('dragover'); + if(e.dataTransfer.files.length > 0) { + validateAndAddImages(Array.from(e.dataTransfer.files)); + } + }); + + // 清空按钮 + addListener(clearBtn, 'click', () => { + snInput.value = ''; + machineTypeSelect.value = ''; + defectSelect.value = ''; + defectOtherInput.value = ''; + defectOtherInput.style.display = 'none'; + methodInput.value = ''; + methodGroup.style.opacity = '0.5'; + methodInput.disabled = true; + methodRequired.style.display = 'none'; + methodInput.placeholder = '请描述维修方法'; + pendingImages = []; + previewContainer.innerHTML = ''; + snError.style.display = 'none'; + machineTypeError.style.display = 'none'; + defectError.style.display = 'none'; + methodError.style.display = 'none'; + imageError.style.display = 'none'; + API.toast('已清空表单'); + }); + + // 获取当前用户角色 + let userRole = window.__userRole || localStorage.getItem('userRole') || ''; + let isSuperAdmin = userRole === 'superadmin'; + + // 如果没有缓存角色,从服务器获取 + if (!userRole) { + try { + const userRes = await fetch('/api/auth/me', { credentials: 'include' }); + const userData = await userRes.json(); + userRole = userData.role || ''; + isSuperAdmin = userRole === 'superadmin'; + window.__userRole = userRole; + localStorage.setItem('userRole', userRole); + } catch (e) { + console.error('Failed to get user role:', e); + } + } + + // 存储选中的记录ID + let selectedIds = new Set(); + + // 更新选中计数 + const updateSelectedCount = () => { + const countEl = document.getElementById('repairs-selected-count'); + if(countEl) { + countEl.textContent = selectedIds.size > 0 ? `已选 ${selectedIds.size} 条` : ''; + } + }; + + // 渲染单条记录的HTML + const renderRecordItem = (r) => { + const ts = new Date(r.ts).toLocaleString('zh-CN'); + const imgCount = (r.images || []).length; + const isChecked = selectedIds.has(r.id); + return ` +
    • +
      + +
      +
      +
      + SN: ${r.sn} + ${ts} +
      +
      + ${r.machine_type ? `${r.machine_type}` : ''} + ${r.defect_reason} + ${imgCount > 0 ? `${imgCount}张图片` : ''} +
      + ${r.repair_method ? `
      维修: ${r.repair_method}
      ` : ''} +
      + + + ${isSuperAdmin ? `` : ''} +
      +
      +
    • + `; + }; + + // 绑定列表项按钮事件 + const bindListItemEvents = () => { + // 勾选框 + document.querySelectorAll('.repair-checkbox').forEach(cb => { + cb.onchange = () => { + const id = parseInt(cb.dataset.id); + if(cb.checked) { + selectedIds.add(id); + } else { + selectedIds.delete(id); + } + updateSelectedCount(); + updateSelectAllState(); + }; + }); + + // 查看按钮 + document.querySelectorAll('.repair-view-btn').forEach(btn => { + btn.onclick = async () => { + const id = btn.dataset.id; + await showRepairDetailModal(id, false); + }; + }); + + // 编辑按钮 + document.querySelectorAll('.repair-edit-btn').forEach(btn => { + btn.onclick = async () => { + const id = btn.dataset.id; + await showRepairDetailModal(id, true); + }; + }); + + // 删除按钮(仅超级管理员可见) + document.querySelectorAll('.repair-delete-btn').forEach(btn => { + btn.onclick = async () => { + const id = btn.dataset.id; + if(confirm('确定要删除这条返修记录吗?此操作不可恢复。')) { + try { + const result = await API.deleteRepairRecord(id); + if(result.ok) { + API.toast('删除成功'); + selectedIds.delete(parseInt(id)); + updateSelectedCount(); + refreshRepairsList(); + } else { + API.toast(result.error || '删除失败'); + } + } catch(e) { + API.toast('删除失败: ' + (e.message || '网络错误')); + } + } + }; + }); + }; + + // 更新全选框状态 + const updateSelectAllState = () => { + const selectAllCb = document.getElementById('repairs-select-all'); + const checkboxes = document.querySelectorAll('.repair-checkbox'); + if(selectAllCb && checkboxes.length > 0) { + const allChecked = Array.from(checkboxes).every(cb => cb.checked); + const someChecked = Array.from(checkboxes).some(cb => cb.checked); + selectAllCb.checked = allChecked; + selectAllCb.indeterminate = someChecked && !allChecked; + } + }; + + // 刷新列表(支持搜索参数) + const refreshRepairsList = async (searchSn = null, showAll = false) => { + const listEl = document.getElementById('repairs-list'); + const infoEl = document.getElementById('repairs-list-info'); + try { + let params = {}; + if(searchSn) { + params.sn = searchSn; + } else if(!showAll) { + params.limit = 10; + } + const data = await API.listRepairRecords(params); + if(listEl && data.list) { + if(data.list.length > 0) { + listEl.innerHTML = data.list.map(renderRecordItem).join(''); + bindListItemEvents(); + if(infoEl) { + if(searchSn) { + infoEl.textContent = `搜索 "${searchSn}" 找到 ${data.list.length} 条记录`; + } else if(showAll) { + infoEl.textContent = `共 ${data.list.length} 条记录`; + } else { + infoEl.textContent = `显示最新 ${data.list.length} 条记录`; + } + } + } else { + listEl.innerHTML = '
    • 暂无记录
    • '; + if(infoEl) { + infoEl.textContent = searchSn ? `未找到包含 "${searchSn}" 的记录` : ''; + } + } + } + } catch(e) { + if(listEl) listEl.innerHTML = '
    • 加载失败
    • '; + if(infoEl) infoEl.textContent = ''; + } + }; + + // 显示详情/编辑弹窗 + const showRepairDetailModal = async (id, isEdit) => { + try { + const record = await API.getRepairRecord(id); + if(record.error) { + API.toast(record.error); + return; + } + + const modalId = 'repair-detail-modal'; + let modal = document.getElementById(modalId); + if(modal) modal.remove(); + + const imagesHtml = (record.images || []).length > 0 + ? `
      + ${record.images.map(img => ` + + + + `).join('')} +
      ` + : '
      无图片
      '; + + const ts = new Date(record.ts).toLocaleString('zh-CN'); + const isNoDefect = record.defect_reason === '无不良'; + + modal = document.createElement('div'); + modal.id = modalId; + modal.innerHTML = ` +
      +
      +
      +

      ${isEdit ? '编辑返修记录' : '返修记录详情'}

      + +
      +
      + ${isEdit ? ` +
      + + +
      +
      + + +
      +
      + + + +
      +
      + + +
      +
      + + ${imagesHtml} +
      编辑模式暂不支持修改图片
      +
      +
      + + +
      + ` : ` +
      +
      设备SN
      +
      ${record.sn}
      +
      +
      +
      机种
      + ${record.machine_type || '未知'} +
      +
      +
      不良原因
      + ${record.defect_reason} +
      +
      +
      维修方法
      +
      ${record.repair_method || '无'}
      +
      +
      +
      上传时间
      +
      ${ts}
      +
      +
      +
      图片
      + ${imagesHtml} +
      +
      + + +
      + `} +
      +
      +
      + `; + document.body.appendChild(modal); + + // 绑定弹窗事件 + document.getElementById('repair-modal-close').onclick = () => modal.remove(); + modal.querySelector('div').onclick = (e) => { if(e.target === modal.querySelector('div')) modal.remove(); }; + + if(isEdit) { + const editDefectSelect = document.getElementById('edit-repair-defect'); + const editDefectOther = document.getElementById('edit-repair-defect-other'); + const editMethodRequired = document.getElementById('edit-method-required'); + + // 不良原因选择变化 + editDefectSelect.onchange = () => { + if(editDefectSelect.value === '其他') { + editDefectOther.style.display = 'block'; + } else { + editDefectOther.style.display = 'none'; + } + editMethodRequired.style.display = editDefectSelect.value === '无不良' ? 'none' : 'inline'; + }; + + document.getElementById('edit-repair-cancel').onclick = () => modal.remove(); + document.getElementById('edit-repair-save').onclick = async () => { + const newSn = document.getElementById('edit-repair-sn').value.trim(); + const newMachineType = document.getElementById('edit-repair-machine-type').value; + let newDefect = editDefectSelect.value; + if(newDefect === '其他') { + newDefect = editDefectOther.value.trim(); + } + const newMethod = document.getElementById('edit-repair-method').value.trim(); + + // 验证 + if(!newSn || newSn.length < 6) { + API.toast('SN至少需要6个字符'); + return; + } + if(!newMachineType) { + API.toast('请选择机种'); + return; + } + if(!newDefect) { + API.toast('请选择或输入不良原因'); + return; + } + if(newDefect !== '无不良' && !newMethod) { + API.toast('选择了不良原因时,维修方法为必填项'); + return; + } + + try { + const result = await API.updateRepairRecord(id, { + sn: newSn, + machine_type: newMachineType, + defect_reason: newDefect, + repair_method: newMethod + }); + if(result.ok) { + API.toast('保存成功'); + modal.remove(); + refreshRepairsList(); + } else { + API.toast(result.error || '保存失败'); + } + } catch(e) { + API.toast('保存失败: ' + (e.message || '网络错误')); + } + }; + } else { + document.getElementById('view-repair-close').onclick = () => modal.remove(); + document.getElementById('view-repair-edit').onclick = () => { + modal.remove(); + showRepairDetailModal(id, true); + }; + } + } catch(e) { + API.toast('加载详情失败'); + } + }; + + // 搜索框和按钮 + const searchInput = document.getElementById('repairs-search-sn'); + const searchBtn = document.getElementById('repairs-search-btn'); + + // 搜索按钮点击 + addListener(searchBtn, 'click', async () => { + const searchSn = searchInput.value.trim(); + if(!searchSn) { + API.toast('请输入SN进行搜索'); + return; + } + await refreshRepairsList(searchSn); + }); + + // 搜索框回车 + addListener(searchInput, 'keypress', async (e) => { + if(e.key === 'Enter') { + const searchSn = searchInput.value.trim(); + if(searchSn) { + await refreshRepairsList(searchSn); + } + } + }); // 查看全部按钮 - 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} 条记录`); + addListener(showAllBtn, 'click', async () => { + searchInput.value = ''; + await refreshRepairsList(null, true); + API.toast('显示全部记录'); + }); + + // 全选复选框 + const selectAllCb = document.getElementById('repairs-select-all'); + addListener(selectAllCb, 'change', () => { + const checkboxes = document.querySelectorAll('.repair-checkbox'); + checkboxes.forEach(cb => { + cb.checked = selectAllCb.checked; + const id = parseInt(cb.dataset.id); + if(selectAllCb.checked) { + selectedIds.add(id); } else { - listEl.innerHTML = '
    • 暂无记录
    • '; + selectedIds.delete(id); } + }); + updateSelectedCount(); + }); + + // 导出按钮 + const exportBtn = document.getElementById('repairs-export-btn'); + addListener(exportBtn, 'click', async () => { + if(selectedIds.size === 0) { + API.toast('请先勾选要导出的记录'); + return; + } + + exportBtn.disabled = true; + exportBtn.textContent = '导出中...'; + + try { + const blob = await API.exportRepairRecords(Array.from(selectedIds)); + // 下载文件 + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `返修记录_${new Date().toISOString().slice(0,10)}.xlsx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + API.toast(`成功导出 ${selectedIds.size} 条记录`); + } catch(e) { + API.toast('导出失败: ' + (e.error || e.message || '网络错误')); + } finally { + exportBtn.disabled = false; + exportBtn.textContent = '导出选中'; } }); - addListener(btn, 'click', async ()=>{ - const qty = parseInt(document.getElementById('repair-qty').value||'0', 10); - const note = document.getElementById('repair-note')?.value.trim() || ''; + // 提交按钮 + addListener(btn, 'click', async () => { + // 表单验证 + let hasError = false; - if(qty <= 0){ - return API.toast('请输入有效的返修数量'); + // 验证SN + const sn = snInput.value.trim(); + if(!sn) { + snError.textContent = '请输入设备SN'; + snError.style.display = 'block'; + hasError = true; + } else if(sn.length < 6) { + snError.textContent = 'SN至少需要6个字符'; + snError.style.display = 'block'; + hasError = true; + } else if(!/^[A-Za-z0-9\-_:]+$/.test(sn)) { + snError.textContent = 'SN只能包含字母、数字、横线、下划线或冒号'; + snError.style.display = 'block'; + hasError = true; } - 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('')||'
    • 暂无数据
    • '; + // 验证机种 + const machineType = machineTypeSelect.value; + if(!machineType) { + machineTypeError.textContent = '请选择机种'; + machineTypeError.style.display = 'block'; + hasError = true; + } + + // 验证不良原因 + let defectReason = defectSelect.value; + if(defectReason === '其他') { + defectReason = defectOtherInput.value.trim(); + if(!defectReason) { + defectError.textContent = '请输入其他原因'; + defectError.style.display = 'block'; + hasError = true; } - - // 清空输入 - document.getElementById('repair-qty').value = ''; - document.getElementById('repair-note').value = ''; - }catch(e){ - API.toast('上传失败'); + } else if(!defectReason) { + defectError.textContent = '请选择不良原因'; + defectError.style.display = 'block'; + hasError = true; + } + + // 验证维修方法:非"无不良"时必填 + const repairMethod = methodInput.value.trim(); + if(defectReason && defectReason !== '无不良' && !repairMethod) { + methodError.textContent = '选择了不良原因时,维修方法为必填项'; + methodError.style.display = 'block'; + hasError = true; + } + + if(hasError) return; + + // 构建FormData + const formData = new FormData(); + formData.append('sn', sn); + formData.append('machine_type', machineType); + formData.append('defect_reason', defectReason); + formData.append('repair_method', methodInput.value.trim()); + + // 添加图片 + pendingImages.forEach(file => { + formData.append('images', file); + }); + + // 显示加载状态 + btn.disabled = true; + uploadText.style.display = 'none'; + uploadLoading.style.display = 'inline-flex'; + + try { + const result = await API.uploadRepairRecord(formData); + if(result.ok) { + API.toast('提交成功'); + + // 清空表单 + snInput.value = ''; + machineTypeSelect.value = ''; + defectSelect.value = ''; + defectOtherInput.value = ''; + defectOtherInput.style.display = 'none'; + methodInput.value = ''; + methodGroup.style.opacity = '0.5'; + methodInput.disabled = true; + methodRequired.style.display = 'none'; + methodInput.placeholder = '请描述维修方法'; + pendingImages = []; + previewContainer.innerHTML = ''; + + // 刷新列表 + await refreshRepairsList(); + } else { + API.toast(result.error || '提交失败'); + } + } catch(e) { + API.toast('提交失败: ' + (e.message || '网络错误')); } finally { - if (btn) btn.disabled = false; + btn.disabled = false; + uploadText.style.display = 'inline'; + uploadLoading.style.display = 'none'; } }); + + // 初始加载最新记录 + setTimeout(refreshRepairsList, 100); } async function bindDefectsEvents(){ diff --git a/server/app.py b/server/app.py index 581f89a..ccd4a95 100644 --- a/server/app.py +++ b/server/app.py @@ -4,7 +4,7 @@ import json import sqlite3 from datetime import datetime from functools import wraps -from flask import Flask, request, jsonify, session, send_from_directory +from flask import Flask, request, jsonify, session, send_from_directory, send_file from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename try: @@ -138,6 +138,20 @@ def init_db(): note TEXT, ts TEXT )''') + c.execute('''CREATE TABLE IF NOT EXISTS repair_records( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sn TEXT NOT NULL, + machine_type TEXT, + defect_reason TEXT NOT NULL, + repair_method TEXT, + images TEXT, + ts TEXT + )''') + # 添加machine_type字段(如果表已存在) + try: + c.execute('ALTER TABLE repair_records ADD COLUMN machine_type TEXT') + except: + pass c.execute('''CREATE TABLE IF NOT EXISTS sop_files( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, @@ -1386,6 +1400,368 @@ def upload_repairs(): return jsonify({'ok': True}) +@app.post('/api/upload/repair-record') +@require_login +@require_any_role('admin','superadmin') +def upload_repair_record(): + """上传返修记录(含设备SN、机种、不良原因、维修方法、图片)""" + sn = request.form.get('sn', '').strip() + machine_type = request.form.get('machine_type', '').strip() + defect_reason = request.form.get('defect_reason', '').strip() + repair_method = request.form.get('repair_method', '').strip() + + # 验证必填字段 + if not sn: + return jsonify({'error': '设备SN为必填项'}), 400 + if not machine_type: + return jsonify({'error': '机种为必填项'}), 400 + if not defect_reason: + return jsonify({'error': '不良原因为必填项'}), 400 + + # 验证维修方法:非"无不良"时必填 + if defect_reason != '无不良' and not repair_method: + return jsonify({'error': '选择了不良原因时,维修方法为必填项'}), 400 + + # 验证SN格式(基本校验:至少6个字符,只包含字母数字和常见分隔符) + import re + if len(sn) < 6 or not re.match(r'^[A-Za-z0-9\-_:]+$', sn): + return jsonify({'error': 'SN格式不正确,应至少6个字符,只包含字母、数字、横线、下划线或冒号'}), 400 + + # 处理图片上传 + image_filenames = [] + upload_dir = os.path.join(os.path.dirname(__file__), 'uploads', 'repair_images') + os.makedirs(upload_dir, exist_ok=True) + + files = request.files.getlist('images') + if len(files) > 10: + return jsonify({'error': '最多上传10张图片'}), 400 + + for f in files: + if f and f.filename: + # 验证文件类型 + ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else '' + if ext not in ['jpg', 'jpeg', 'png', 'gif']: + return jsonify({'error': f'不支持的图片格式: {ext},仅支持JPG/PNG/GIF'}), 400 + + # 验证文件大小(10MB) + f.seek(0, 2) + size = f.tell() + f.seek(0) + if size > 10 * 1024 * 1024: + return jsonify({'error': f'图片 {f.filename} 超过10MB限制'}), 400 + + # 保存文件 - 使用简单的时间戳格式避免特殊字符 + import time + timestamp = int(time.time() * 1000) + filename = f"{timestamp}_{secure_filename(f.filename)}" + filepath = os.path.join(upload_dir, filename) + f.save(filepath) + image_filenames.append(filename) + + # 保存到数据库 + conn = get_db() + c = conn.cursor() + now = get_beijing_time() + images_json = json.dumps(image_filenames) if image_filenames else '' + + c.execute('INSERT INTO repair_records(sn, machine_type, defect_reason, repair_method, images, ts) VALUES(?,?,?,?,?,?)', + (sn, machine_type, defect_reason, repair_method, images_json, now)) + conn.commit() + record_id = c.lastrowid + conn.close() + + log('upload_repair_record', json.dumps({'sn': sn, 'machine_type': machine_type, 'defect_reason': defect_reason, 'images_count': len(image_filenames)})) + notify_superadmin('上传返修记录', f"SN: {sn}, 机种: {machine_type}, 不良原因: {defect_reason}") + return jsonify({'ok': True, 'id': record_id}) + + +@app.get('/api/list/repair-records') +@require_login +def list_repair_records(): + """获取返修记录列表,支持SN搜索和分页""" + sn_query = request.args.get('sn', '').strip() + limit = request.args.get('limit', '') + + conn = get_db() + c = conn.cursor() + + if sn_query: + # 支持模糊搜索 + c.execute('SELECT id, sn, machine_type, defect_reason, repair_method, images, ts FROM repair_records WHERE sn LIKE ? ORDER BY id DESC', (f'%{sn_query}%',)) + elif limit: + try: + lim = int(limit) + c.execute('SELECT id, sn, machine_type, defect_reason, repair_method, images, ts FROM repair_records ORDER BY id DESC LIMIT ?', (lim,)) + except: + c.execute('SELECT id, sn, machine_type, defect_reason, repair_method, images, ts FROM repair_records ORDER BY id DESC') + else: + # 不限制数量,返回所有记录 + c.execute('SELECT id, sn, machine_type, defect_reason, repair_method, images, ts FROM repair_records ORDER BY id DESC') + + rows = [dict(r) for r in c.fetchall()] + conn.close() + + # 解析images JSON + for r in rows: + if r.get('images'): + try: + r['images'] = json.loads(r['images']) + except: + r['images'] = [] + else: + r['images'] = [] + + return jsonify({'list': rows, 'total': len(rows)}) + + +@app.get('/api/repair-image/') +@require_login +def get_repair_image(filename): + """获取返修记录图片""" + upload_dir = os.path.join(os.path.dirname(__file__), 'uploads', 'repair_images') + filepath = os.path.join(upload_dir, secure_filename(filename)) + if os.path.exists(filepath): + return send_file(filepath) + return jsonify({'error': 'not found'}), 404 + + +@app.post('/api/export/repair-records') +@require_login +@require_any_role('admin','superadmin') +def export_repair_records(): + """导出返修记录到Excel(含图片)""" + try: + from openpyxl import Workbook + from openpyxl.drawing.image import Image as XLImage + from openpyxl.utils import get_column_letter + from io import BytesIO + from PIL import Image as PILImage + import tempfile + + data = request.get_json() or {} + record_ids = data.get('ids', []) + + if not record_ids: + return jsonify({'error': '请选择要导出的记录'}), 400 + + conn = get_db() + c = conn.cursor() + + # 获取选中的记录 + placeholders = ','.join(['?' for _ in record_ids]) + c.execute(f'SELECT id, sn, machine_type, defect_reason, repair_method, images, ts FROM repair_records WHERE id IN ({placeholders}) ORDER BY id DESC', record_ids) + rows = [dict(r) for r in c.fetchall()] + conn.close() + + if not rows: + return jsonify({'error': '未找到记录'}), 404 + + # 创建Excel工作簿 + wb = Workbook() + ws = wb.active + ws.title = '返修记录' + + from openpyxl.styles import Alignment + + # 设置表头 + headers = ['序号', 'SN', '机种', '不良原因', '维修方法', '时间', '图片'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.alignment = Alignment(horizontal='left', vertical='center') + ws.column_dimensions[get_column_letter(col)].width = 20 + + # 序号列宽度缩小 + ws.column_dimensions['A'].width = 4.5 + # 图片列宽度更大 + ws.column_dimensions['G'].width = 60 + + upload_dir = os.path.join(os.path.dirname(__file__), 'uploads', 'repair_images') + + # 单元格对齐样式:垂直居中、左对齐 + data_alignment = Alignment(horizontal='left', vertical='center') + + # 填充数据 + for row_idx, record in enumerate(rows, 2): + cell1 = ws.cell(row=row_idx, column=1, value=row_idx - 1) + cell1.alignment = data_alignment + cell2 = ws.cell(row=row_idx, column=2, value=record['sn']) + cell2.alignment = data_alignment + cell3 = ws.cell(row=row_idx, column=3, value=record.get('machine_type') or '') + cell3.alignment = data_alignment + cell4 = ws.cell(row=row_idx, column=4, value=record['defect_reason']) + cell4.alignment = data_alignment + cell5 = ws.cell(row=row_idx, column=5, value=record['repair_method'] or '') + cell5.alignment = data_alignment + # 格式化时间:去掉T和时区,只显示到秒 + ts = record['ts'] + if ts: + ts = ts.replace('T', ' ').split('.')[0].split('+')[0] + cell6 = ws.cell(row=row_idx, column=6, value=ts) + cell6.alignment = data_alignment + + # 处理图片 + images = [] + if record['images']: + try: + images = json.loads(record['images']) if isinstance(record['images'], str) else record['images'] + except: + images = [] + + if images: + # 设置行高以容纳图片(增大到150像素) + ws.row_dimensions[row_idx].height = 150 + + img_col = 7 # 图片列改为第7列 + for img_idx, img_name in enumerate(images[:3]): # 最多显示3张图片 + img_path = os.path.join(upload_dir, secure_filename(img_name)) + if os.path.exists(img_path): + try: + # 压缩图片(增大到200x200) + with PILImage.open(img_path) as pil_img: + pil_img.thumbnail((300, 300)) + img_buffer = BytesIO() + pil_img.save(img_buffer, format='PNG') + img_buffer.seek(0) + + xl_img = XLImage(img_buffer) + xl_img.width = 180 + xl_img.height = 180 + + cell = ws.cell(row=row_idx, column=img_col + img_idx) + ws.add_image(xl_img, cell.coordinate) + except Exception as e: + ws.cell(row=row_idx, column=img_col, value=f'图片加载失败: {img_name}') + else: + ws.cell(row=row_idx, column=img_col, value=f'图片不存在: {img_name}') + else: + ws.cell(row=row_idx, column=7, value='无图片') + + # 保存到内存 + output = BytesIO() + wb.save(output) + output.seek(0) + + log('export_repair_records', json.dumps({'count': len(rows)})) + + filename = f'返修记录_{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_repair_records_error', str(e)) + return jsonify({'error': f'导出失败: {str(e)}'}), 500 + + +@app.get('/api/repair-record/') +@require_login +def get_repair_record(record_id): + """获取单条返修记录详情""" + conn = get_db() + c = conn.cursor() + c.execute('SELECT id, sn, machine_type, defect_reason, repair_method, images, ts FROM repair_records WHERE id=?', (record_id,)) + row = c.fetchone() + conn.close() + + if not row: + return jsonify({'error': '记录不存在'}), 404 + + record = dict(row) + if record.get('images'): + try: + record['images'] = json.loads(record['images']) + except: + record['images'] = [] + else: + record['images'] = [] + + return jsonify(record) + + +@app.put('/api/repair-record/') +@require_login +@require_any_role('admin','superadmin') +def update_repair_record(record_id): + """更新返修记录""" + data = request.get_json() or {} + sn = data.get('sn', '').strip() + machine_type = data.get('machine_type', '').strip() + defect_reason = data.get('defect_reason', '').strip() + repair_method = data.get('repair_method', '').strip() + + # 验证必填字段 + if not sn: + return jsonify({'error': '设备SN为必填项'}), 400 + if not machine_type: + return jsonify({'error': '机种为必填项'}), 400 + if not defect_reason: + return jsonify({'error': '不良原因为必填项'}), 400 + + # 验证维修方法:非"无不良"时必填 + if defect_reason != '无不良' and not repair_method: + return jsonify({'error': '选择了不良原因时,维修方法为必填项'}), 400 + + # 验证SN格式 + import re + if len(sn) < 6 or not re.match(r'^[A-Za-z0-9\-_:]+$', sn): + return jsonify({'error': 'SN格式不正确'}), 400 + + conn = get_db() + c = conn.cursor() + + # 检查记录是否存在 + c.execute('SELECT id FROM repair_records WHERE id=?', (record_id,)) + if not c.fetchone(): + conn.close() + return jsonify({'error': '记录不存在'}), 404 + + c.execute('UPDATE repair_records SET sn=?, machine_type=?, defect_reason=?, repair_method=? WHERE id=?', + (sn, machine_type, defect_reason, repair_method, record_id)) + conn.commit() + conn.close() + + log('update_repair_record', json.dumps({'id': record_id, 'sn': sn, 'defect_reason': defect_reason})) + return jsonify({'ok': True}) + + +@app.delete('/api/repair-record/') +@require_login +@require_any_role('superadmin') +def delete_repair_record(record_id): + """删除返修记录(仅超级管理员)""" + conn = get_db() + c = conn.cursor() + + # 获取记录信息(用于删除图片) + c.execute('SELECT images FROM repair_records WHERE id=?', (record_id,)) + row = c.fetchone() + if not row: + conn.close() + return jsonify({'error': '记录不存在'}), 404 + + # 删除关联的图片文件 + if row['images']: + try: + images = json.loads(row['images']) + upload_dir = os.path.join(os.path.dirname(__file__), 'uploads', 'repair_images') + for img in images: + filepath = os.path.join(upload_dir, secure_filename(img)) + if os.path.exists(filepath): + os.remove(filepath) + except: + pass + + # 删除数据库记录 + c.execute('DELETE FROM repair_records WHERE id=?', (record_id,)) + conn.commit() + conn.close() + + log('delete_repair_record', json.dumps({'id': record_id})) + return jsonify({'ok': True}) + + @app.post('/api/upload/defects') @require_login @require_any_role('admin','superadmin')