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 () => {
-
@@ -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')}
+
+
+
+
+
+
+
+ 上传进度
+ 0%
+
+
+
+ 0 MB / 0 MB
+ 0 KB/s
+
+
+
+ `);
+ }
+
+ 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()