diff --git a/app.py b/app.py index 312a033..506e03c 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,19 @@ import json from datetime import datetime import glob 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.config['UPLOAD_FOLDER'] = 'uploads' @@ -186,6 +199,154 @@ def cleanup_files(): except Exception as e: 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): """查找表头所在的行索引""" try: @@ -417,4 +578,11 @@ if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) # 生产环境建议关闭 debug 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) \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index a0ec36a..304b25a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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 */ @media (max-width: 640px) { .app-container { @@ -747,4 +989,19 @@ body { align-items: center; 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; + } } \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index c596645..329ebb6 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -395,6 +395,109 @@ function closeUploadModal() { 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 = ' 下载中...'; + + 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 = ' 重试'; + } + }) + .catch(err => { + statusText.textContent = '请求失败: ' + err.message; + btn.disabled = false; + btn.innerHTML = ' 重试'; + }); +} + +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 = ' 开始下载'; + + if (status.last_file) { + // 下载成功,刷新文件列表 + setTimeout(() => { + closeAutoDownloadModal(); + loadFileList(); + }, 1500); + } + } + } + }) + .catch(() => { }); + }, 2000); +} + function showLoading(show, text) { elements.loadingOverlay.style.display = show ? 'flex' : 'none'; if (text) elements.loadingText.textContent = text; diff --git a/templates/index.html b/templates/index.html index f486b47..d071e77 100644 --- a/templates/index.html +++ b/templates/index.html @@ -30,6 +30,12 @@ + + + 设置 + + + +
diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..5723108 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,238 @@ + + + + + + + 设置 - SaleShow + + + + + +
+
+ +

系统设置

+
+ +
+ + + + +
+
+

secsion.com 账号配置

+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ + 在 secsion.com 统计页面的店铺下拉框中查看店铺名称,填写店铺 ID 或名称 +
+
+
+
+ + +
+
+

定时自动下载

+
+
+
+ + +
+
+ + +
+
+ + 每天自动下载前一天的销售数据,需保持服务运行 +
+
+
+ + +
+
+

服务状态

+
+
+
+ 调度器状态 + 加载中... +
+
+ 下次执行 + -- +
+
+
+ + +
+ +
+
+
+ + +
+
+
+
+
+
+
+

保存中...

+
+
+ + + + +