优化细节
This commit is contained in:
parent
b9a6b80651
commit
48737b892e
@ -47,6 +47,10 @@
|
|||||||
<span class="child-icon">📦</span>
|
<span class="child-icon">📦</span>
|
||||||
<span>发货记录</span>
|
<span>发货记录</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#/upload/sop" class="nav-child" data-route="upload-sop">
|
||||||
|
<span class="child-icon">📄</span>
|
||||||
|
<span>SOP</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -63,6 +63,50 @@ const API = (() => {
|
|||||||
overlay.classList.add('hidden');
|
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) {
|
function toast(msg) {
|
||||||
const t = document.getElementById('toast');
|
const t = document.getElementById('toast');
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
@ -117,7 +161,21 @@ const API = (() => {
|
|||||||
markNotificationRead: id => requestQuiet('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ id }) }),
|
markNotificationRead: id => requestQuiet('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ id }) }),
|
||||||
markAllNotificationsRead: () => requestQuiet('/notifications/mark-all-read', { method: 'POST' }),
|
markAllNotificationsRead: () => requestQuiet('/notifications/mark-all-read', { method: 'POST' }),
|
||||||
deleteReadNotifications: () => requestQuiet('/notifications/delete-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>
|
<label>选择头像图片</label>
|
||||||
<input type="file" id="avatar-file" accept="image/*" class="input" style="padding:6px" />
|
<input type="file" id="avatar-file" accept="image/*" class="input" style="padding:6px" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@ -126,9 +126,9 @@ Router.register('/settings', async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小(限制2MB)
|
// 验证文件大小(限制5MB)
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
API.toast('图片大小不能超过2MB');
|
API.toast('图片大小不能超过5MB');
|
||||||
avatarFileInput.value = '';
|
avatarFileInput.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -234,6 +234,190 @@ const Upload = (() => {
|
|||||||
return html;
|
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 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){
|
function parseManual(text){
|
||||||
|
|||||||
@ -84,6 +84,7 @@ const Router = (() => {
|
|||||||
defects: '不良明细',
|
defects: '不良明细',
|
||||||
repairs: '返修记录',
|
repairs: '返修记录',
|
||||||
shipments: '发货',
|
shipments: '发货',
|
||||||
|
sop: 'SOP',
|
||||||
query: '详细记录查询',
|
query: '详细记录查询',
|
||||||
summary: '汇总信息查询',
|
summary: '汇总信息查询',
|
||||||
devices: '设备状态',
|
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 = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path='')
|
||||||
app.config['SECRET_KEY'] = os.environ.get('APP_SECRET', 'change-me')
|
app.config['SECRET_KEY'] = os.environ.get('APP_SECRET', 'change-me')
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 限制上传文件大小为50MB
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@ -133,6 +134,14 @@ def init_db():
|
|||||||
note TEXT,
|
note TEXT,
|
||||||
ts 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()
|
conn.commit()
|
||||||
# create default admin
|
# create default admin
|
||||||
c.execute('SELECT id FROM users WHERE username=?', ('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
|
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()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user