feat: 新增自动下载 API 和设置页面 UI

This commit is contained in:
2026-04-29 16:18:31 +08:00
parent 89b01bb522
commit 75bdc94cfe
5 changed files with 808 additions and 0 deletions
+168
View File
@@ -6,6 +6,19 @@ import json
from datetime import datetime from datetime import datetime
import glob import glob
import time import time
import asyncio
import threading
import logging
from config import Config
from automation.uploader import import_excel_file, cleanup_download
from automation.scheduler import init_scheduler, get_scheduler_status, shutdown_scheduler
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads' app.config['UPLOAD_FOLDER'] = 'uploads'
@@ -186,6 +199,154 @@ def cleanup_files():
except Exception as e: except Exception as e:
return jsonify({'error': f'清理文件失败: {str(e)}'}), 500 return jsonify({'error': f'清理文件失败: {str(e)}'}), 500
# ============ 自动化相关路由 ============
# 全局下载任务状态
download_status = {
'running': False,
'message': '',
'last_run': None,
'last_file': None
}
@app.route('/settings')
def settings_page():
"""设置页面"""
return render_template('settings.html')
@app.route('/api/settings', methods=['GET'])
def get_settings():
"""获取配置"""
try:
return jsonify({'success': True, 'data': Config.get_all_config()})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/settings', methods=['POST'])
def save_settings():
"""保存配置"""
try:
data = request.get_json()
# 保存凭据
if 'secsion' in data:
secsion = data['secsion']
username = secsion.get('username', '').strip()
password = secsion.get('password', '').strip()
shop_id = secsion.get('shop_id', '').strip()
if username and password and password != '******':
Config.save_secsion_credentials(username, password)
if shop_id is not None:
Config.save_shop_id(shop_id)
# 保存调度配置
if 'scheduler' in data:
sched = data['scheduler']
Config.save_schedule_config(
enabled=sched.get('enabled', True),
hour=sched.get('hour', 1),
minute=sched.get('minute', 0)
)
return jsonify({'success': True, 'message': '配置已保存'})
except Exception as e:
return jsonify({'error': f'保存配置失败: {str(e)}'}), 500
@app.route('/api/auto-download', methods=['POST'])
def auto_download():
"""触发自动下载"""
global download_status
if download_status['running']:
return jsonify({'error': '已有下载任务正在执行,请稍候'}), 409
try:
data = request.get_json() or {}
start_date = data.get('start_date')
end_date = data.get('end_date', start_date)
if not start_date:
from datetime import timedelta
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
start_date = yesterday
end_date = yesterday
# 检查凭据
creds = Config.get_secsion_credentials()
if not creds:
return jsonify({'error': '未配置 secsion.com 登录凭据,请先在设置页面配置'}), 400
username, password = creds
# 在后台线程执行下载
def run_download():
global download_status
download_status['running'] = True
download_status['message'] = f'正在下载 {start_date} ~ {end_date} 的数据...'
try:
from automation.secsion import SecsionDownloader
shop_id = Config.get_shop_id()
downloader = SecsionDownloader(username, password, download_dir='downloads', shop_id=shop_id)
file_path = asyncio.run(downloader.download_report(start_date, end_date))
if file_path:
imported_name = import_excel_file(file_path, upload_dir='uploads')
cleanup_download(file_path)
if imported_name:
download_status['message'] = f'下载完成: {imported_name}'
download_status['last_file'] = imported_name
logger.info(f"自动下载并导入成功: {imported_name}")
else:
download_status['message'] = '下载成功但导入失败'
else:
download_status['message'] = '下载失败:未获取到文件'
except Exception as e:
download_status['message'] = f'下载异常: {str(e)}'
logger.error(f"自动下载异常: {e}", exc_info=True)
finally:
download_status['running'] = False
download_status['last_run'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
thread = threading.Thread(target=run_download, daemon=True)
thread.start()
return jsonify({
'success': True,
'message': f'已开始下载 {start_date} ~ {end_date} 的数据'
})
except Exception as e:
return jsonify({'error': f'启动下载失败: {str(e)}'}), 500
@app.route('/api/auto-download/status', methods=['GET'])
def get_download_status():
"""获取下载任务状态"""
return jsonify({
'success': True,
'status': download_status
})
@app.route('/api/scheduler/status', methods=['GET'])
def get_scheduler():
"""获取定时任务状态"""
return jsonify({
'success': True,
'status': get_scheduler_status()
})
# ============ 数据处理函数 ============
def find_header_row(filepath): def find_header_row(filepath):
"""查找表头所在的行索引""" """查找表头所在的行索引"""
try: try:
@@ -417,4 +578,11 @@ if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000)) port = int(os.environ.get('PORT', 5000))
# 生产环境建议关闭 debug # 生产环境建议关闭 debug
debug_mode = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true' debug_mode = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
# 初始化定时任务调度器
try:
init_scheduler(app)
except Exception as e:
logger.warning(f"定时任务调度器初始化失败: {e}")
app.run(debug=debug_mode, host='0.0.0.0', port=port) app.run(debug=debug_mode, host='0.0.0.0', port=port)
+257
View File
@@ -681,6 +681,248 @@ body {
} }
} }
/* Accent Button */
.btn-accent {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-accent:hover {
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.5);
transform: translateY(-2px);
}
.btn-accent:active {
transform: scale(0.98);
}
/* Settings Page */
.settings-nav {
margin-bottom: 20px;
}
.settings-section {
padding: 24px;
margin-bottom: 20px;
}
.settings-section .section-header {
margin-bottom: 20px;
}
.settings-section .section-header h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.settings-section .section-header h2 i {
color: var(--primary-color);
}
.settings-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="time"],
.form-input {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 14px;
background: white;
transition: all 0.2s;
}
.form-group input:focus,
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.form-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.password-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-wrapper input {
flex: 1;
padding-right: 40px;
}
.btn-toggle-pwd {
position: absolute;
right: 10px;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
font-size: 14px;
padding: 4px;
}
.btn-toggle-pwd:hover {
color: var(--primary-color);
}
/* Toggle Switch */
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #cbd5e1;
transition: 0.3s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
.form-hint {
font-size: 12px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 6px;
padding: 8px 0;
}
.form-hint i {
color: var(--primary-light);
}
/* Status Panel */
.status-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(226, 232, 240, 0.5);
}
.status-item:last-child {
border-bottom: none;
}
.status-label {
font-size: 13px;
color: var(--text-secondary);
}
.status-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.status-ok {
color: #10b981;
}
.status-off {
color: #94a3b8;
}
.settings-actions {
text-align: center;
padding: 20px 0 40px;
}
/* Auto Download Modal */
.download-status {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: rgba(99, 102, 241, 0.05);
border-radius: 10px;
margin-top: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.loader-dots.small div {
width: 6px;
height: 6px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
}
/* Mobile Response */ /* Mobile Response */
@media (max-width: 640px) { @media (max-width: 640px) {
.app-container { .app-container {
@@ -747,4 +989,19 @@ body {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.form-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.modal-actions {
flex-direction: column;
}
.modal-actions .btn {
width: 100%;
justify-content: center;
}
} }
+103
View File
@@ -395,6 +395,109 @@ function closeUploadModal() {
elements.uploadModal.classList.remove('active'); elements.uploadModal.classList.remove('active');
} }
// --- Auto Download ---
let autoDownloadPollTimer = null;
function openAutoDownloadModal() {
const modal = document.getElementById('autoDownloadModal');
// 默认日期为昨天
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T')[0];
document.getElementById('autoStartDate').value = dateStr;
document.getElementById('autoEndDate').value = dateStr;
document.getElementById('autoDownloadStatus').style.display = 'none';
document.getElementById('autoDownloadBtn').disabled = false;
modal.classList.add('active');
}
function closeAutoDownloadModal() {
const modal = document.getElementById('autoDownloadModal');
modal.classList.remove('active');
if (autoDownloadPollTimer) {
clearInterval(autoDownloadPollTimer);
autoDownloadPollTimer = null;
}
}
function startAutoDownload() {
const startDate = document.getElementById('autoStartDate').value;
const endDate = document.getElementById('autoEndDate').value;
if (!startDate) {
alert('请选择开始日期');
return;
}
const btn = document.getElementById('autoDownloadBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 下载中...';
const statusDiv = document.getElementById('autoDownloadStatus');
const statusText = document.getElementById('autoDownloadStatusText');
statusDiv.style.display = 'flex';
statusText.textContent = '正在启动下载任务...';
fetch('/api/auto-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_date: startDate, end_date: endDate || startDate })
})
.then(res => res.json())
.then(data => {
if (data.success) {
statusText.textContent = data.message;
// 开始轮询状态
pollAutoDownloadStatus();
} else {
statusText.textContent = data.error || '启动失败';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-download"></i> 重试';
}
})
.catch(err => {
statusText.textContent = '请求失败: ' + err.message;
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-download"></i> 重试';
});
}
function pollAutoDownloadStatus() {
if (autoDownloadPollTimer) clearInterval(autoDownloadPollTimer);
autoDownloadPollTimer = setInterval(() => {
fetch('/api/auto-download/status')
.then(res => res.json())
.then(data => {
if (data.success) {
const status = data.status;
const statusText = document.getElementById('autoDownloadStatusText');
const btn = document.getElementById('autoDownloadBtn');
statusText.textContent = status.message;
if (!status.running) {
clearInterval(autoDownloadPollTimer);
autoDownloadPollTimer = null;
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-download"></i> 开始下载';
if (status.last_file) {
// 下载成功,刷新文件列表
setTimeout(() => {
closeAutoDownloadModal();
loadFileList();
}, 1500);
}
}
}
})
.catch(() => { });
}, 2000);
}
function showLoading(show, text) { function showLoading(show, text) {
elements.loadingOverlay.style.display = show ? 'flex' : 'none'; elements.loadingOverlay.style.display = show ? 'flex' : 'none';
if (text) elements.loadingText.textContent = text; if (text) elements.loadingText.textContent = text;
+42
View File
@@ -30,6 +30,12 @@
<button class="btn btn-primary btn-lg btn-block" onclick="openUploadModal()"> <button class="btn btn-primary btn-lg btn-block" onclick="openUploadModal()">
<i class="fas fa-plus-circle"></i> 上传新文件 <i class="fas fa-plus-circle"></i> 上传新文件
</button> </button>
<button class="btn btn-accent btn-lg btn-block" onclick="openAutoDownloadModal()">
<i class="fas fa-cloud-download-alt"></i> 自动获取
</button>
<a href="/settings" class="btn btn-text btn-sm" title="系统设置">
<i class="fas fa-cog"></i> 设置
</a>
<div class="file-selector-wrapper" id="fileSelector" style="display: none;"> <div class="file-selector-wrapper" id="fileSelector" style="display: none;">
<span class="label">当前分析:</span> <span class="label">当前分析:</span>
@@ -130,6 +136,42 @@
</div> </div>
</div> </div>
<!-- 自动获取弹窗 -->
<div id="autoDownloadModal" class="modal-overlay">
<div class="modal-card bounce-in">
<div class="modal-header">
<h3><i class="fas fa-cloud-download-alt"></i> 自动获取数据</h3>
<button class="btn-close" onclick="closeAutoDownloadModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="autoStartDate">开始日期</label>
<input type="date" id="autoStartDate" class="form-input">
</div>
<div class="form-group">
<label for="autoEndDate">结束日期</label>
<input type="date" id="autoEndDate" class="form-input">
</div>
<div class="form-hint" id="autoDownloadHint">
<i class="fas fa-info-circle"></i>
将从 secsion.com 自动下载指定日期范围的销售数据
</div>
<div id="autoDownloadStatus" class="download-status" style="display: none;">
<div class="loader-dots small">
<div></div><div></div><div></div>
</div>
<span id="autoDownloadStatusText">准备中...</span>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeAutoDownloadModal()">取消</button>
<button class="btn btn-primary" id="autoDownloadBtn" onclick="startAutoDownload()">
<i class="fas fa-download"></i> 开始下载
</button>
</div>
</div>
</div>
</div>
<!-- 加载层 --> <!-- 加载层 -->
<div id="loadingOverlay" class="loading-overlay"> <div id="loadingOverlay" class="loading-overlay">
<div class="loading-box"> <div class="loading-box">
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>设置 - SaleShow</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="app-container">
<header class="main-header">
<div class="logo">
<i class="fas fa-chart-pie"></i>
<h1>SaleShow</h1>
</div>
<p class="subtitle">系统设置</p>
</header>
<main class="content-area">
<!-- 导航 -->
<div class="settings-nav">
<a href="/" class="btn btn-outline btn-sm">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<!-- secsion.com 凭据配置 -->
<section class="settings-section glass-card">
<div class="section-header">
<h2><i class="fas fa-key"></i> secsion.com 账号配置</h2>
</div>
<div class="settings-form">
<div class="form-group">
<label for="secsionUsername">用户名</label>
<input type="text" id="secsionUsername" placeholder="请输入 secsion.com 用户名" autocomplete="off">
</div>
<div class="form-group">
<label for="secsionPassword">密码</label>
<div class="password-wrapper">
<input type="password" id="secsionPassword" placeholder="请输入 secsion.com 密码" autocomplete="off">
<button type="button" class="btn-toggle-pwd" onclick="togglePassword()">
<i class="fas fa-eye" id="pwdToggleIcon"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="shopId">店铺 ID</label>
<input type="text" id="shopId" placeholder="留空则导出所有店铺数据" autocomplete="off">
<div class="form-hint">
<i class="fas fa-info-circle"></i>
在 secsion.com 统计页面的店铺下拉框中查看店铺名称,填写店铺 ID 或名称
</div>
</div>
</div>
</section>
<!-- 定时任务配置 -->
<section class="settings-section glass-card">
<div class="section-header">
<h2><i class="fas fa-clock"></i> 定时自动下载</h2>
</div>
<div class="settings-form">
<div class="form-group form-row">
<label>启用定时任务</label>
<label class="switch">
<input type="checkbox" id="schedulerEnabled" checked>
<span class="slider"></span>
</label>
</div>
<div class="form-group form-row">
<label for="schedulerTime">每日执行时间</label>
<input type="time" id="schedulerTime" value="01:00">
</div>
<div class="form-hint">
<i class="fas fa-info-circle"></i>
每天自动下载前一天的销售数据,需保持服务运行
</div>
</div>
</section>
<!-- 调度器状态 -->
<section class="settings-section glass-card">
<div class="section-header">
<h2><i class="fas fa-heartbeat"></i> 服务状态</h2>
</div>
<div class="status-panel" id="schedulerStatus">
<div class="status-item">
<span class="status-label">调度器状态</span>
<span class="status-value" id="schedulerRunning">加载中...</span>
</div>
<div class="status-item">
<span class="status-label">下次执行</span>
<span class="status-value" id="schedulerNextRun">--</span>
</div>
</div>
</section>
<!-- 保存按钮 -->
<div class="settings-actions">
<button class="btn btn-primary btn-lg" onclick="saveSettings()">
<i class="fas fa-save"></i> 保存设置
</button>
</div>
</main>
</div>
<!-- 加载层 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-box">
<div class="loader-dots">
<div></div>
<div></div>
<div></div>
</div>
<p id="loadingText">保存中...</p>
</div>
</div>
<script>
// 加载当前配置
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadSchedulerStatus();
});
function loadSettings() {
fetch('/api/settings')
.then(res => res.json())
.then(data => {
if (data.success) {
const secsion = data.data.secsion || {};
const scheduler = data.data.scheduler || {};
document.getElementById('secsionUsername').value = secsion.username || '';
document.getElementById('secsionPassword').value = secsion.password || '';
document.getElementById('shopId').value = secsion.shop_id || '';
document.getElementById('schedulerEnabled').checked = scheduler.enabled !== false;
if (scheduler.hour !== undefined) {
const h = String(scheduler.hour).padStart(2, '0');
const m = String(scheduler.minute || 0).padStart(2, '0');
document.getElementById('schedulerTime').value = `${h}:${m}`;
}
}
})
.catch(err => console.error('加载配置失败:', err));
}
function loadSchedulerStatus() {
fetch('/api/scheduler/status')
.then(res => res.json())
.then(data => {
if (data.success) {
const status = data.status;
document.getElementById('schedulerRunning').textContent =
status.running ? '运行中' : '已停止';
document.getElementById('schedulerRunning').className =
'status-value ' + (status.running ? 'status-ok' : 'status-off');
if (status.jobs && status.jobs.length > 0) {
document.getElementById('schedulerNextRun').textContent =
status.jobs[0].next_run || '--';
}
}
})
.catch(err => console.error('加载调度器状态失败:', err));
}
function saveSettings() {
const username = document.getElementById('secsionUsername').value.trim();
const password = document.getElementById('secsionPassword').value;
const shopId = document.getElementById('shopId').value.trim();
const enabled = document.getElementById('schedulerEnabled').checked;
const timeVal = document.getElementById('schedulerTime').value;
const [hour, minute] = timeVal.split(':').map(Number);
if (!username || (!password || password === '******')) {
if (!username) {
alert('请输入 secsion.com 用户名');
return;
}
}
const body = {
secsion: {
username: username,
password: password,
shop_id: shopId
},
scheduler: {
enabled: enabled,
hour: hour,
minute: minute
}
};
const overlay = document.getElementById('loadingOverlay');
overlay.style.display = 'flex';
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
.then(res => res.json())
.then(data => {
overlay.style.display = 'none';
if (data.success) {
alert('设置已保存');
loadSchedulerStatus();
} else {
alert(data.error || '保存失败');
}
})
.catch(err => {
overlay.style.display = 'none';
alert('保存错误: ' + err.message);
});
}
function togglePassword() {
const pwdInput = document.getElementById('secsionPassword');
const icon = document.getElementById('pwdToggleIcon');
if (pwdInput.type === 'password') {
pwdInput.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
pwdInput.type = 'password';
icon.className = 'fas fa-eye';
}
}
</script>
</body>
</html>