From 48737b892e82f9a1f1f9542e55a7c5d11b253de0 Mon Sep 17 00:00:00 2001 From: zzh Date: Sat, 22 Nov 2025 20:40:46 +0800 Subject: [PATCH] =?UTF-8?q?=20=E4=BC=98=E5=8C=96=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 4 + frontend/js/api.js | 60 +++++++++- frontend/js/components/settings.js | 8 +- frontend/js/components/upload.js | 184 +++++++++++++++++++++++++++++ frontend/js/router.js | 1 + server/app.py | 144 ++++++++++++++++++++++ 6 files changed, 396 insertions(+), 5 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 308ae97..b727597 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -47,6 +47,10 @@ 📦 发货记录 + + 📄 + SOP + diff --git a/frontend/js/api.js b/frontend/js/api.js index 31e5c8d..4b53321 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -63,6 +63,50 @@ const API = (() => { overlay.classList.add('hidden'); } } + + async function uploadFileWithProgress(path, formData, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // 上传进度 + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable && onProgress) { + const percent = Math.round((e.loaded / e.total) * 100); + const loaded = e.loaded; + const total = e.total; + onProgress({ percent, loaded, total }); + } + }); + + // 完成 + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const result = JSON.parse(xhr.responseText); + resolve(result); + } catch (e) { + reject(new Error('解析响应失败')); + } + } else { + reject(new Error(xhr.responseText || '上传失败')); + } + }); + + // 错误 + xhr.addEventListener('error', () => { + reject(new Error('网络错误')); + }); + + // 中止 + xhr.addEventListener('abort', () => { + reject(new Error('上传已取消')); + }); + + xhr.open('POST', base + path); + xhr.withCredentials = true; + xhr.send(formData); + }); + } function toast(msg) { const t = document.getElementById('toast'); t.textContent = msg; @@ -117,7 +161,21 @@ const API = (() => { 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' }) + updateShipmentsPlatform: () => request('/shipments/update-platform', { method: 'POST' }), + listSopFiles: () => request('/sop/list'), + uploadSopFile: (file, description) => { + const fd = new FormData(); + fd.append('file', file); + fd.append('description', description); + return uploadFile('/sop/upload', fd); + }, + uploadSopFileWithProgress: (file, description, onProgress) => { + const fd = new FormData(); + fd.append('file', file); + fd.append('description', description); + return uploadFileWithProgress('/sop/upload', fd, onProgress); + }, + deleteSopFile: id => request(`/sop/delete/${id}`, { method: 'POST' }) }; })(); diff --git a/frontend/js/components/settings.js b/frontend/js/components/settings.js index 09eb4b0..3f2c338 100644 --- a/frontend/js/components/settings.js +++ b/frontend/js/components/settings.js @@ -18,7 +18,7 @@ Router.register('/settings', async () => { -
支持 JPG、PNG、GIF 等图片格式,建议尺寸 200x200 像素
+
支持 JPG、PNG、GIF(动图)、WEBP 等图片格式,最大 5MB,建议尺寸 200x200 像素
@@ -126,9 +126,9 @@ Router.register('/settings', async () => { return; } - // 验证文件大小(限制2MB) - if (file.size > 2 * 1024 * 1024) { - API.toast('图片大小不能超过2MB'); + // 验证文件大小(限制5MB) + if (file.size > 5 * 1024 * 1024) { + API.toast('图片大小不能超过5MB'); avatarFileInput.value = ''; return; } diff --git a/frontend/js/components/upload.js b/frontend/js/components/upload.js index b52af7f..9a8d072 100644 --- a/frontend/js/components/upload.js +++ b/frontend/js/components/upload.js @@ -234,6 +234,190 @@ const Upload = (() => { return html; }); + async function renderSop(){ + return section('SOP 文件管理',` +
+
📄 SOP 说明
+
+
• 所有用户均可在线查看和下载 SOP 文件
+
• 管理员可以上传新的 SOP 文件(支持 Excel 和 Word 格式)
+
• 建议为每个 SOP 文件添加清晰的描述说明
+
+
+ ${filePicker('sop-file','上传 SOP 文件 (Excel/Word)','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,text/csv,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword')} +
+ + +
+
+ +
+
SOP 文件列表
+
    +
    + `); + } + + Router.register('/upload/sop', async () => { + const html = await renderSop(); + setTimeout(bindSopEvents,0); + setTimeout(loadSopList,0); + return html; + }); + + async function loadSopList(){ + const listEl = document.getElementById('sop-list'); + if(!listEl) return; + + try { + const data = await API.listSopFiles(); + if(data.list && data.list.length > 0){ + listEl.innerHTML = data.list.map(r=>{ + const ts = new Date(r.ts).toLocaleString('zh-CN'); + const desc = r.description ? `
    ${r.description}
    ` : ''; + const uploader = r.uploader ? `上传者: ${r.uploader}` : ''; + + return `
  • +
    +
    ${r.original_name}
    + ${desc} +
    ${uploader} • ${ts}
    +
    +
    + 下载 + +
    +
  • `; + }).join(''); + + // 绑定删除按钮事件 + document.querySelectorAll('.sop-delete').forEach(btn => { + addListener(btn, 'click', async (e) => { + const id = e.target.dataset.id; + if(!confirm('确定要删除这个 SOP 文件吗?')) return; + + try { + await API.deleteSopFile(id); + API.toast('删除成功'); + loadSopList(); + } catch(e) { + API.toast('删除失败'); + } + }); + }); + } else { + listEl.innerHTML = '
  • 暂无 SOP 文件
  • '; + } + } catch(e) { + listEl.innerHTML = '
  • 加载失败
  • '; + } + } + + async function bindSopEvents(){ + const fileEl = document.getElementById('sop-file'); + const btn = document.getElementById('sop-upload'); + const descEl = document.getElementById('sop-description'); + + addListener(btn, 'click', async ()=>{ + const file = fileEl.files[0]; + if(!file){ + API.toast('请选择文件'); + return; + } + + const description = descEl ? descEl.value.trim() : ''; + + // 获取进度显示元素 + const progressContainer = document.getElementById('sop-upload-progress'); + const progressBar = document.getElementById('sop-progress-bar'); + const progressPercent = document.getElementById('sop-progress-percent'); + const progressSize = document.getElementById('sop-progress-size'); + const progressSpeed = document.getElementById('sop-progress-speed'); + + let startTime = Date.now(); + let lastLoaded = 0; + let lastTime = Date.now(); + + try { + btn.disabled = true; + + // 显示进度条 + if(progressContainer) progressContainer.style.display = 'block'; + + await API.uploadSopFileWithProgress(file, description, (progress) => { + const { percent, loaded, total } = progress; + + // 更新进度条 + if(progressBar) progressBar.style.width = percent + '%'; + if(progressPercent) progressPercent.textContent = percent + '%'; + + // 更新大小 + const loadedMB = (loaded / 1024 / 1024).toFixed(2); + const totalMB = (total / 1024 / 1024).toFixed(2); + if(progressSize) progressSize.textContent = `${loadedMB} MB / ${totalMB} MB`; + + // 计算速度 + const now = Date.now(); + const timeDiff = (now - lastTime) / 1000; // 秒 + if(timeDiff > 0.1) { // 每100ms更新一次速度 + const loadedDiff = loaded - lastLoaded; + const speed = loadedDiff / timeDiff; // bytes/s + + let speedText; + if(speed > 1024 * 1024) { + speedText = (speed / 1024 / 1024).toFixed(2) + ' MB/s'; + } else if(speed > 1024) { + speedText = (speed / 1024).toFixed(2) + ' KB/s'; + } else { + speedText = speed.toFixed(0) + ' B/s'; + } + + if(progressSpeed) progressSpeed.textContent = speedText; + + lastLoaded = loaded; + lastTime = now; + } + }); + + API.toast('上传成功'); + + // 清空输入 + if(fileEl) fileEl.value = ''; + if(descEl) descEl.value = ''; + + // 隐藏进度条 + setTimeout(() => { + if(progressContainer) progressContainer.style.display = 'none'; + if(progressBar) progressBar.style.width = '0%'; + if(progressPercent) progressPercent.textContent = '0%'; + if(progressSize) progressSize.textContent = '0 MB / 0 MB'; + if(progressSpeed) progressSpeed.textContent = '0 KB/s'; + }, 2000); + + // 刷新列表 + loadSopList(); + } catch(e) { + API.toast('上传失败'); + // 隐藏进度条 + if(progressContainer) progressContainer.style.display = 'none'; + } finally { + if(btn) btn.disabled = false; + } + }); + } + 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){ diff --git a/frontend/js/router.js b/frontend/js/router.js index ac9f7dc..c759fe6 100644 --- a/frontend/js/router.js +++ b/frontend/js/router.js @@ -84,6 +84,7 @@ const Router = (() => { defects: '不良明细', repairs: '返修记录', shipments: '发货', + sop: 'SOP', query: '详细记录查询', summary: '汇总信息查询', devices: '设备状态', diff --git a/server/app.py b/server/app.py index 5cd3d07..bf2e468 100644 --- a/server/app.py +++ b/server/app.py @@ -20,6 +20,7 @@ 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') +app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 限制上传文件大小为50MB def get_db(): @@ -133,6 +134,14 @@ def init_db(): note TEXT, ts TEXT )''') + c.execute('''CREATE TABLE IF NOT EXISTS sop_files( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + description TEXT, + uploader TEXT, + ts TEXT + )''') conn.commit() # create default admin c.execute('SELECT id FROM users WHERE username=?', ('admin',)) @@ -2352,6 +2361,141 @@ def upload_shipments_file(): return jsonify({'error': f'处理文件失败:{str(e)}'}), 500 +# SOP 文件管理 +SOP_DIR = os.path.join(FRONTEND_DIR, 'sop_files') +os.makedirs(SOP_DIR, exist_ok=True) + +@app.get('/api/sop/list') +@require_login +def list_sop_files(): + """获取所有 SOP 文件列表""" + conn = get_db() + c = conn.cursor() + c.execute('SELECT id, filename, original_name, description, uploader, ts FROM sop_files ORDER BY id DESC') + rows = [dict(r) for r in c.fetchall()] + conn.close() + return jsonify({'list': rows}) + + +@app.post('/api/sop/upload') +@require_login +@require_any_role('admin','superadmin') +def upload_sop_file(): + """上传 SOP 文件""" + f = request.files.get('file') + description = request.form.get('description', '').strip() + + if not f: + return jsonify({'error': '请选择文件'}), 400 + + # 保留原始文件名(包含中文) + original_name = f.filename or 'sop.xlsx' + + # 获取扩展名 + if '.' in original_name: + ext = original_name.rsplit('.', 1)[1].lower() + else: + ext = '' + + # 验证文件类型(Excel 和 Word 文件) + if ext not in ['xlsx', 'xls', 'csv', 'doc', 'docx']: + return jsonify({'error': '只支持 Excel 和 Word 文件格式(.xlsx, .xls, .csv, .doc, .docx)'}), 400 + + # 生成唯一文件名(用于存储) + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + # 存储文件名使用安全的名称 + safe_name = secure_filename(original_name) + if not safe_name or safe_name == ext: + # 如果 secure_filename 返回空或只有扩展名,使用时间戳 + safe_name = f'file.{ext}' + filename = f'sop_{timestamp}_{safe_name}' + filepath = os.path.join(SOP_DIR, filename) + + # 保存文件 + f.save(filepath) + + # 保存到数据库 + conn = get_db() + c = conn.cursor() + uploader = session.get('user_id') + + # 获取上传者用户名 + c.execute('SELECT username FROM users WHERE id=?', (uploader,)) + user = c.fetchone() + uploader_name = user['username'] if user else '未知' + + c.execute('INSERT INTO sop_files(filename, original_name, description, uploader, ts) VALUES(?,?,?,?,?)', + (filename, original_name, description, uploader_name, get_beijing_time())) + conn.commit() + conn.close() + + log('upload_sop', f'filename={original_name}, description={description}') + notify_superadmin('上传 SOP 文件', f'文件名: {original_name}') + + return jsonify({'ok': True, 'message': 'SOP 文件上传成功'}) + + +@app.get('/api/sop/download/') +@require_login +def download_sop_file(file_id): + """下载 SOP 文件""" + conn = get_db() + c = conn.cursor() + c.execute('SELECT filename, original_name FROM sop_files WHERE id=?', (file_id,)) + row = c.fetchone() + conn.close() + + if not row: + return jsonify({'error': '文件不存在'}), 404 + + filepath = os.path.join(SOP_DIR, row['filename']) + + if not os.path.exists(filepath): + return jsonify({'error': '文件已被删除'}), 404 + + log('download_sop', f'file_id={file_id}, filename={row["original_name"]}') + + from flask import send_file + return send_file( + filepath, + as_attachment=True, + download_name=row['original_name'] + ) + + +@app.post('/api/sop/delete/') +@require_login +@require_any_role('admin','superadmin') +def delete_sop_file(file_id): + """删除 SOP 文件""" + conn = get_db() + c = conn.cursor() + c.execute('SELECT filename, original_name FROM sop_files WHERE id=?', (file_id,)) + row = c.fetchone() + + if not row: + conn.close() + return jsonify({'error': '文件不存在'}), 404 + + # 删除物理文件 + filepath = os.path.join(SOP_DIR, row['filename']) + if os.path.exists(filepath): + try: + os.remove(filepath) + except Exception as e: + log('delete_sop_file_error', str(e)) + + # 删除数据库记录 + c.execute('DELETE FROM sop_files WHERE id=?', (file_id,)) + conn.commit() + conn.close() + + log('delete_sop', f'file_id={file_id}, filename={row["original_name"]}') + notify_superadmin('删除 SOP 文件', f'文件名: {row["original_name"]}') + + return jsonify({'ok': True, 'message': 'SOP 文件已删除'}) + + init_db()