优化细节
This commit is contained in:
parent
b9a6b80651
commit
48737b892e
@ -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>
|
||||
|
||||
@ -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' })
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@ -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)">支持 JPG、PNG、GIF 等图片格式,建议尺寸 200x200 像素</div>
|
||||
<div style="font-size:12px;color:var(--text-2)">支持 JPG、PNG、GIF(动图)、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;
|
||||
}
|
||||
|
||||
@ -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){
|
||||
|
||||
@ -84,6 +84,7 @@ const Router = (() => {
|
||||
defects: '不良明细',
|
||||
repairs: '返修记录',
|
||||
shipments: '发货',
|
||||
sop: 'SOP',
|
||||
query: '详细记录查询',
|
||||
summary: '汇总信息查询',
|
||||
devices: '设备状态',
|
||||
|
||||
144
server/app.py
144
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/<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()
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user