-
最新记录
-
+ return section('返修记录上传',`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
历史记录
+
+
+
+
+
+
+
+
+
`);
}
@@ -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 ? `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ` : `
+
+
+
机种
+
${record.machine_type || '未知'}
+
+
+
不良原因
+
${record.defect_reason}
+
+
+
维修方法
+
${record.repair_method || '无'}
+
+
+
+
+
+
+
+ `}
+
+
+
+ `;
+ 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')