优化细节

This commit is contained in:
zzh 2025-11-22 20:40:46 +08:00
parent b9a6b80651
commit 48737b892e
6 changed files with 396 additions and 5 deletions

View File

@ -47,6 +47,10 @@
<span class="child-icon">📦</span>
<span>发货记录</span>
</a>
<a href="#/upload/sop" class="nav-child" data-route="upload-sop">
<span class="child-icon">📄</span>
<span>SOP</span>
</a>
</div>
</div>
</div>

View File

@ -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' })
};
})();

View File

@ -18,7 +18,7 @@ Router.register('/settings', async () => {
<label>选择头像图片</label>
<input type="file" id="avatar-file" accept="image/*" class="input" style="padding:6px" />
</div>
<div style="font-size:12px;color:var(--text-2)">支持 JPGPNGGIF 等图片格式建议尺寸 200x200 像素</div>
<div style="font-size:12px;color:var(--text-2)">支持 JPGPNGGIF动图WEBP 等图片格式最大 5MB建议尺寸 200x200 像素</div>
</div>
</div>
<div class="actions">
@ -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;
}

View File

@ -234,6 +234,190 @@ const Upload = (() => {
return html;
});
async function renderSop(){
return section('SOP 文件管理',`
<div style="background:rgba(79,140,255,0.08);border:1px solid rgba(79,140,255,0.2);border-radius:8px;padding:12px;margin-bottom:12px;font-size:13px;line-height:1.6">
<div style="font-weight:600;margin-bottom:6px;color:var(--primary)">📄 SOP 说明</div>
<div style="color:var(--text)">
<div> 所有用户均可在线查看和下载 SOP 文件</div>
<div> 管理员可以上传新的 SOP 文件支持 Excel Word 格式</div>
<div> 建议为每个 SOP 文件添加清晰的描述说明</div>
</div>
</div>
${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')}
<div class="field">
<label>文件描述可选</label>
<textarea id="sop-description" class="input" rows="2" placeholder="例如:拼多多生产流程 SOP v1.0"></textarea>
</div>
<div class="actions"><button class="btn" id="sop-upload">上传</button></div>
<div id="sop-upload-progress" style="display:none;margin-top:12px;padding:12px;background:var(--surface);border-radius:8px;border:1px solid var(--border)">
<div style="display:flex;justify-content:space-between;margin-bottom:8px">
<span style="font-weight:500">上传进度</span>
<span id="sop-progress-percent">0%</span>
</div>
<div style="width:100%;height:8px;background:var(--bg);border-radius:4px;overflow:hidden">
<div id="sop-progress-bar" style="width:0%;height:100%;background:var(--primary);transition:width 0.3s"></div>
</div>
<div style="display:flex;justify-content:space-between;margin-top:8px;font-size:12px;color:var(--text-2)">
<span id="sop-progress-size">0 MB / 0 MB</span>
<span id="sop-progress-speed">0 KB/s</span>
</div>
</div>
<div style="margin-top:12px">
<div style="font-weight:600;margin-bottom:8px">SOP 文件列表</div>
<ul id="sop-list" class="list" style="max-height:400px;overflow-y:auto"></ul>
</div>
`);
}
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 ? `<div style="font-size:11px;color:var(--text-2);margin-top:2px">${r.description}</div>` : '';
const uploader = r.uploader ? `<span style="font-size:11px;color:var(--text-2)">上传者: ${r.uploader}</span>` : '';
return `<li style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px">
<div style="flex:1">
<div style="font-weight:500">${r.original_name}</div>
${desc}
<div style="font-size:11px;color:var(--text-2);margin-top:4px">${uploader} ${ts}</div>
</div>
<div style="display:flex;gap:8px">
<a href="/api/sop/download/${r.id}" class="btn btn-secondary" style="font-size:12px;padding:4px 12px;text-decoration:none" download>下载</a>
<button class="btn btn-secondary sop-delete" data-id="${r.id}" style="font-size:12px;padding:4px 12px;background:var(--danger);color:white">删除</button>
</div>
</li>`;
}).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 = '<li>暂无 SOP 文件</li>';
}
} catch(e) {
listEl.innerHTML = '<li>加载失败</li>';
}
}
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){

View File

@ -84,6 +84,7 @@ const Router = (() => {
defects: '不良明细',
repairs: '返修记录',
shipments: '发货',
sop: 'SOP',
query: '详细记录查询',
summary: '汇总信息查询',
devices: '设备状态',

View File

@ -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/<int:file_id>')
@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/<int:file_id>')
@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()