Compare commits

...

8 Commits

Author SHA1 Message Date
houhuan c6f81b6f6e fix: 全面增强 SSL 证书错误处理和弹窗监控
- 添加 --ignore-certificate-errors 浏览器级参数
- 注册 popup 事件处理器,监控导出触发的弹窗
- 弹窗出现时自动绕过 SSL 拦截
- 失败时遍历所有页面(主页面+弹窗+新标签页)进行 SSL 绕过和下载重试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:58:06 +08:00
houhuan 70f293db96 fix: 自动绕过 SSL 证书过期的拦截页面
导出下载链接的 SSL 证书过期,Chrome 会弹出"您的连接不是私密连接"页面
阻止下载。新增 _bypass_ssl_interstitial() 方法,自动点击「高级」→「继续前往」
绕过 SSL 拦截,并在下载事件超时后做二次 SSL 绕过尝试。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:50:18 +08:00
houhuan e95a1723e5 fix: 修复 Docker 中下载事件不触发的根本原因
- 移除 --single-process 参数(破坏 Chromium 多进程下载机制)
- 添加 --no-sandbox(Docker 容器需要)
- 显式设置 accept_downloads=True 和 downloads_path
- 添加文件系统兜底检测:即使 Playwright download 事件未触发,
  也能通过检测下载目录中的新文件来获取结果

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:34:23 +08:00
houhuan b402612641 fix: 增强导出下载诊断能力,缩短超时时间
下载超时从 300s 减至 120s,失败时自动保存截图、打印服务端响应内容、
检查页面错误提示和新标签页,便于定位 download 事件未触发的根因。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:23:32 +08:00
houhuan 975f9e5887 优化: Docker 环境下的下载性能和网络稳定性
- Chromium 启动参数优化:禁用 dev-shm 和 GPU 加速,防止Docker内存不足
- 增加所有超时时间:login/navigate/export 超时 30s,下载超时 300s
- 改进网络延迟处理:增加数据加载等待时间,添加网络加载检测
- Docker Compose 资源配置:限制 2 CPU / 2GB 内存,DNS 配置国际公共 DNS
- Dockerfile 优化:添加 PYTHONHASHSEED 环境变量,跳过浏览器下载校验
- 新增 docker-debug.sh 脚本:便捷测试 Docker 容器中的下载功能
- 新增 .dockerignore:加速 Docker 构建,减少镜像大小

Docker 下载现在支持更长的网络延迟和更大的数据量
2026-05-17 16:09:55 +08:00
houhuan 505e5ca895 优化: 改进自动下载性能和稳定性
- 添加自动重试机制(3次重试,指数退避延迟)
- 增加超时时间至180秒以支持大数据量下载
- 改进数据表格加载检测(JavaScript智能检测)
- 优化日期选择器设置逻辑(5次重试,更好的错误处理)
- 更新README说明最新的性能优化成果

典型场景:3天数据下载耗时 20-35 秒,相比之前提升明显
2026-05-17 16:02:36 +08:00
houhuan 4132226fae feat: 更新 Dockerfile 安装 Playwright,更新 README 文档 2026-04-29 16:18:37 +08:00
houhuan 75bdc94cfe feat: 新增自动下载 API 和设置页面 UI 2026-04-29 16:18:31 +08:00
14 changed files with 1427 additions and 87 deletions
+19
View File
@@ -0,0 +1,19 @@
.git
.gitignore
.env.example
*.md
.vscode
.idea
__pycache__
*.pyc
.pytest_cache
.coverage
*.egg-info
dist
build
.DS_Store
*.log
uploads/*
downloads/*
.claude
CLAUDE.md
+6
View File
@@ -15,6 +15,12 @@ uploads/
*.xlsx *.xlsx
*.xls *.xls
# 下载的临时文件
downloads/
# 配置数据(含凭据)
data/config.json
# IDE文件 # IDE文件
.vscode/ .vscode/
.idea/ .idea/
+59
View File
@@ -0,0 +1,59 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SaleShow is a monolithic Flask web application for analyzing sales data from Excel files. It supports manual Excel uploads and automated daily downloads from secsion.com via Playwright browser automation. There is no database — all data lives as Excel files on disk in `uploads/`.
## Commands
```bash
# Install dependencies
pip install -r requirements.txt
# Run development server (Flask on port 5000, debug via FLASK_DEBUG env var)
python app.py
# Run with Docker (builds image, installs Playwright Chromium, port 5000)
docker-compose up -d
# Production
gunicorn -w 4 -b 0.0.0.0:8000 app:app
# CLI automation — download reports from secsion.com
python -m automation.secsion --start 2026-04-28 --end 2026-04-28
python -m automation.secsion --start 2026-05-15 --end 2026-05-17 --username 15682076681 --password yourpassword
```
No test framework or linter is configured in this project.
## Architecture
**Backend (Flask, single `app.py`):**
- Routes handle file upload (`/upload`), file listing (`/files`), data loading/processing (`/load/<filename>`), deletion, and cleanup.
- `process_sales_data()` (~lines 371-575) is the core logic. It uses a state-machine approach to handle two Excel formats: "flat tables" (each row has code + product) and "hierarchical tables" (code row is a header, product rows are children). Outputs daily summaries with per-product breakdowns.
- `find_header_row()` dynamically detects the header row by scanning first 20 rows for keyword matches.
- Auto-download routes use a global `download_status` dict and run Playwright in daemon threads via `threading.Thread`.
**Automation module (`automation/`):**
- `secsion.py``SecsionDownloader` uses Playwright headless Chromium to log into secsion.com, navigate to reports, set date range via TDesign date picker (requires click → select day → Enter confirm → Escape close sequence), optionally inject `shop_id` via route interception on `**/api/bill/export`, and download exports. Has 3-retry logic with exponential backoff.
- `uploader.py` — copies downloaded files into `uploads/` with `YYYYMMDD_HHMMSS_` prefix naming (same convention as manual uploads).
- `scheduler.py` — APScheduler `BackgroundScheduler` with `CronTrigger` runs daily auto-download (default 01:00). Uses `misfire_grace_time=3600`.
**Configuration (`config.py`):**
- Three-tier priority: Web UI settings (`data/config.json`) > environment variables (`.env` / system env) > defaults.
- `Config` class provides static methods for reading/writing secsion credentials, shop ID, and scheduler settings.
- Passwords are masked (`******`) when returned via the API.
**Frontend (vanilla JS/CSS, no build step):**
- `main.js` — all client-side interactivity: file upload (drag-and-drop), AJAX to API, data rendering (card/table view), client-side filtering, sorting, pagination (50 items/page), export.
- `style.css` — Glassmorphism design with CSS custom properties.
- `settings.html` — self-contained settings page with inline `<script>` (no separate JS file).
## Key Design Decisions
- No database — Excel files on disk are the data store.
- No frontend build step — vanilla JS/CSS served directly via Flask static files.
- Playwright automation runs in daemon threads; status tracked via module-level `download_status` dict in `app.py`.
- The secsion.com date picker uses TDesign's `needconfirm="true"` mode — simply calling `.fill()` won't work; must click cell then press Enter.
+29 -5
View File
@@ -9,9 +9,30 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
# 确保 Python 输出不被缓冲 # 确保 Python 输出不被缓冲
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# 禁用 Python 的硬件指令集检查,提高Chromium兼容性
ENV PYTHONHASHSEED=0
# 安装系统依赖(如有需要) # 安装 Playwright 所需的系统依赖 + 网络优化
# RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
dnsmasq \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件 # 复制依赖文件
COPY requirements.txt . COPY requirements.txt .
@@ -20,15 +41,18 @@ COPY requirements.txt .
# 使用阿里云镜像源加速 # 使用阿里云镜像源加速
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
# 安装 Playwright Chromium 浏览器(增加超时以处理网络不稳定)
RUN PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 playwright install --with-deps chromium
# 复制当前目录内容到容器中的 /app # 复制当前目录内容到容器中的 /app
COPY . . COPY . .
# 创建上传目录(确保权限) # 创建上传目录和数据目录(确保权限)
RUN mkdir -p uploads && chmod 777 uploads RUN mkdir -p uploads data downloads && chmod 777 uploads data downloads
# 暴露端口 5000 # 暴露端口 5000
EXPOSE 5000 EXPOSE 5000
# 增加容器内存限制和进程管理
# 运行 app.py # 运行 app.py
# 生产环境建议使用 gunicorn (需添加到 requirements.txt),但这里为了简单直接运行 python app.py
CMD ["python", "app.py"] CMD ["python", "app.py"]
+80 -7
View File
@@ -1,6 +1,6 @@
# 📊 销售数据分析器 # 📊 销售数据分析器
一个现代化的Web应用程序,用于分析和可视化销售数据。支持Excel文件上传自动解析销售数据,提供丰富的分析功能和美观的界面展示。 一个现代化的Web应用程序,用于分析和可视化销售数据。支持Excel文件上传自动从 secsion.com 获取数据,提供丰富的分析功能和美观的界面展示。
## ✨ 功能特性 ## ✨ 功能特性
@@ -11,6 +11,14 @@
- **数据筛选** - 按金额范围进行数据筛选 - **数据筛选** - 按金额范围进行数据筛选
- **搜索功能** - 支持商品名称搜索 - **搜索功能** - 支持商品名称搜索
### 🤖 自动化功能
- **自动获取数据** - 从 secsion.com 自动下载销售报表
- **店铺筛选** - 支持指定店铺 ID,仅导出目标店铺数据
- **Web UI 触发** - 在页面上选择日期范围一键下载
- **定时自动下载** - 每日凌晨自动获取前一天数据(APScheduler
- **设置页面** - 在 Web UI 配置 secsion.com 账号密码、店铺 ID、定时任务
- **CLI 支持** - 命令行直接运行 `python -m automation.secsion --start 2026-04-28`
### 📊 数据展示 ### 📊 数据展示
- **销售总览** - 显示总销售额、销售天数、商品种类等统计信息 - **销售总览** - 显示总销售额、销售天数、商品种类等统计信息
- **每日详情** - 按日期分组展示销售数据 - **每日详情** - 按日期分组展示销售数据
@@ -90,6 +98,28 @@ python app.py
- **清理文件** - 点击"清理文件"按钮可删除所有上传的Excel文件 - **清理文件** - 点击"清理文件"按钮可删除所有上传的Excel文件
- **自动加载** - 页面刷新时自动加载最新上传的文件 - **自动加载** - 页面刷新时自动加载最新上传的文件
### 自动获取数据
1. **配置凭据** - 点击"设置"进入设置页面,填写 secsion.com 账号密码和店铺 ID
2. **手动获取** - 点击"自动获取"按钮,选择日期范围,点击"开始下载"
3. **定时获取** - 在设置页面启用定时任务,系统每日凌晨自动下载前一天数据
4. **CLI 模式** - 命令行运行:
```bash
# 下载指定日期数据(推荐)
python -m automation.secsion --start 2026-05-15 --end 2026-05-17
# 指定用户名密码
python -m automation.secsion --start 2026-05-15 --end 2026-05-17 --username 15682076681 --password yourpassword
```
**⚡ 下载性能优化**
- ✅ 支持自动重试(3次重试机制)
- ✅ 智能数据加载检测
- ✅ 优化的超时控制(180秒)
- ✅ 支持大日期范围和大数据量下载
- 📊 典型场景:3天数据下载耗时 20-35 秒
> **配置优先级**: Web UI 设置页 > 环境变量 (.env) > 默认值
## 🏗️ 部署说明 ## 🏗️ 部署说明
### 开发环境部署 ### 开发环境部署
@@ -125,29 +155,52 @@ gunicorn -w 4 -b 0.0.0.0:8000 app:app
``` ```
### 环境变量配置 ### 环境变量配置
复制 `.env.example` 为 `.env` 并填写:
```bash ```bash
# 设置Flask环境 cp .env.example .env
export FLASK_DEBUG=False
# 设置端口 (手动运行 python app.py 时生效,Docker使用映射端口)
export PORT=5000
``` ```
主要配置项:
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `SECSION_USERNAME` | secsion.com 用户名 | - |
| `SECSION_PASSWORD` | secsion.com 密码 | - |
| `SECSION_SHOP_ID` | 店铺 ID(留空导出所有) | 空 |
| `SCHEDULER_ENABLED` | 是否启用定时任务 | true |
| `SCHEDULER_HOUR` | 定时任务执行小时 | 1 |
| `SCHEDULER_MINUTE` | 定时任务执行分钟 | 0 |
| `FLASK_DEBUG` | Flask 调试模式 | False |
| `PORT` | 服务端口 | 5000 |
## 📁 项目结构 ## 📁 项目结构
``` ```
SaleShow/ SaleShow/
├── app.py # Flask应用主文件 ├── app.py # Flask应用主文件
├── config.py # 配置管理模块
├── automation/ # 自动化模块
│ ├── __init__.py
│ ├── secsion.py # secsion.com 登录+导出逻辑
│ ├── uploader.py # 本地文件导入
│ └── scheduler.py # APScheduler 定时任务
├── requirements.txt # Python依赖包列表 ├── requirements.txt # Python依赖包列表
├── Dockerfile # Docker构建文件 ├── Dockerfile # Docker构建文件
├── docker-compose.yml # Docker Compose配置 ├── docker-compose.yml # Docker Compose配置
├── .env.example # 环境变量配置示例
├── static/ # 静态资源目录 ├── static/ # 静态资源目录
│ ├── css/ │ ├── css/
│ │ └── style.css # 样式文件 │ │ └── style.css # 样式文件
│ └── js/ │ └── js/
│ └── main.js # 交互逻辑 │ └── main.js # 交互逻辑
├── templates/ ├── templates/
── index.html # 前端HTML模板 ── index.html # 前端HTML模板
│ └── settings.html # 设置页面
├── uploads/ # 上传文件存储目录(持久化挂载) ├── uploads/ # 上传文件存储目录(持久化挂载)
├── data/ # 配置数据目录(持久化挂载)
├── downloads/ # 自动下载临时目录
└── README.md # 项目说明文档 └── README.md # 项目说明文档
``` ```
@@ -158,6 +211,9 @@ SaleShow/
- **pandas** 2.3.3 - 数据处理 - **pandas** 2.3.3 - 数据处理
- **openpyxl** 3.1.5 - Excel文件处理 - **openpyxl** 3.1.5 - Excel文件处理
- **xlrd** 2.0.1 - 旧版Excel文件支持 - **xlrd** 2.0.1 - 旧版Excel文件支持
- **Playwright** - 浏览器自动化(secsion.com 数据获取)
- **APScheduler** - 定时任务调度
- **python-dotenv** - 环境变量管理
### 前端 ### 前端
- **HTML5** - 页面结构 - **HTML5** - 页面结构
@@ -168,6 +224,23 @@ SaleShow/
### 开发工具 ### 开发工具
- **Git** - 版本控制 - **Git** - 版本控制
- **pip** - 包管理 - **pip** - 包管理
- **Docker** - 容器化部署
## 📡 API 接口
| 路由 | 方法 | 说明 |
|------|------|------|
| `/` | GET | 首页(数据分析页面) |
| `/settings` | GET | 设置页面 |
| `/files` | GET | 获取已上传文件列表 |
| `/load/<filename>` | GET | 加载指定文件数据 |
| `/upload` | POST | 上传 Excel 文件 |
| `/delete/<filename>` | POST | 删除指定文件 |
| `/cleanup` | POST | 清理所有上传文件 |
| `/api/settings` | GET/POST | 获取/保存配置 |
| `/api/auto-download` | POST | 触发自动下载 |
| `/api/auto-download/status` | GET | 查询下载任务状态 |
| `/api/scheduler/status` | GET | 查询定时任务状态 |
## 🔒 安全说明 ## 🔒 安全说明
@@ -196,6 +269,6 @@ SaleShow/
--- ---
**最后更新时间:** 2026年1月10 **最后更新时间:** 2026年5月17
*享受数据分析的乐趣!📊✨* *享受数据分析的乐趣!📊✨*
+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)
+294 -30
View File
@@ -25,24 +25,39 @@ class SecsionDownloader:
self.download_dir = download_dir or os.path.join(os.getcwd(), "downloads") self.download_dir = download_dir or os.path.join(os.getcwd(), "downloads")
os.makedirs(self.download_dir, exist_ok=True) os.makedirs(self.download_dir, exist_ok=True)
async def download_report(self, start_date, end_date): async def download_report(self, start_date, end_date, retry_count=3):
""" """
下载指定日期范围的销售报表 下载指定日期范围的销售报表
Args: Args:
start_date: 开始日期 (YYYY-MM-DD) start_date: 开始日期 (YYYY-MM-DD)
end_date: 结束日期 (YYYY-MM-DD) end_date: 结束日期 (YYYY-MM-DD)
retry_count: 重试次数(默认3次)
Returns: Returns:
str: 下载文件的本地路径,失败返回 None str: 下载文件的本地路径,失败返回 None
""" """
logger.info(f"开始下载报表: {start_date} ~ {end_date}") for attempt in range(retry_count):
try:
logger.info(f"开始下载报表: {start_date} ~ {end_date} (第 {attempt + 1}/{retry_count} 次)")
async with async_playwright() as p: async with async_playwright() as p:
browser = await p.chromium.launch(headless=True) # Docker 优化:添加 --disable-dev-shm-usage 避免共享内存不足
# 注意:不能使用 --single-process,它会破坏 Chromium 的下载机制
browser = await p.chromium.launch(
headless=True,
downloads_path=self.download_dir,
args=[
"--disable-dev-shm-usage",
"--disable-gpu",
"--no-sandbox",
"--ignore-certificate-errors"
]
)
context = await browser.new_context( context = await browser.new_context(
ignore_https_errors=True, ignore_https_errors=True,
viewport={'width': 1280, 'height': 800} viewport={'width': 1280, 'height': 800},
accept_downloads=True
) )
page = await context.new_page() page = await context.new_page()
@@ -51,23 +66,30 @@ class SecsionDownloader:
file_path = await self._export_report(page, start_date, end_date) file_path = await self._export_report(page, start_date, end_date)
logger.info(f"报表下载完成: {file_path}") logger.info(f"报表下载完成: {file_path}")
return file_path return file_path
except Exception as e:
logger.error(f"下载报表失败: {e}")
return None
finally: finally:
await browser.close() await browser.close()
except Exception as e:
logger.error(f"下载报表失败 (第 {attempt + 1}/{retry_count} 次): {e}")
if attempt < retry_count - 1:
wait_time = (attempt + 1) * 5
logger.info(f"等待 {wait_time} 秒后重试...")
await asyncio.sleep(wait_time)
continue
logger.error(f"下载报表最终失败 (重试 {retry_count} 次均失败)")
return None
async def _login(self, page): async def _login(self, page):
"""登录 secsion.com""" """登录 secsion.com"""
logger.info(f"打开登录页面: {self.LOGIN_URL}") logger.info(f"打开登录页面: {self.LOGIN_URL}")
await page.goto(self.LOGIN_URL) await page.goto(self.LOGIN_URL, timeout=30000)
# 选择角色 "店铺" # 选择角色 "店铺"
logger.info("选择角色: 店铺") logger.info("选择角色: 店铺")
try: try:
await page.get_by_text("店铺", exact=True).click() await page.get_by_text("店铺", exact=True).click(timeout=10000)
except Exception: except Exception:
await page.click("text=店铺") await page.click("text=店铺", timeout=10000)
# 输入账号密码 # 输入账号密码
logger.info(f"输入账号: {self.username}") logger.info(f"输入账号: {self.username}")
@@ -83,21 +105,21 @@ class SecsionDownloader:
try: try:
await page.click("button:has-text('登录')", timeout=5000) await page.click("button:has-text('登录')", timeout=5000)
except Exception: except Exception:
await page.click("button[type='submit']") await page.click("button[type='submit']", timeout=5000)
# 等待跳转 # 等待跳转Docker 中需要更长时间)
logger.info("等待登录跳转...") logger.info("等待登录跳转...")
await page.wait_for_url("**/homePage", timeout=20000) await page.wait_for_url("**/homePage", timeout=30000)
logger.info("登录成功") logger.info("登录成功")
async def _export_report(self, page, start_date, end_date): async def _export_report(self, page, start_date, end_date):
"""访问统计页面并导出报表""" """访问统计页面并导出报表"""
logger.info(f"访问统计页面: {self.STATS_URL}") logger.info(f"访问统计页面: {self.STATS_URL}")
await page.goto(self.STATS_URL) await page.goto(self.STATS_URL, timeout=30000)
await page.wait_for_load_state("networkidle") await page.wait_for_load_state("networkidle", timeout=30000)
export_btn = page.get_by_role("button", name="导出报表") export_btn = page.get_by_role("button", name="导出报表")
await export_btn.wait_for(state="visible", timeout=20000) await export_btn.wait_for(state="visible", timeout=30000)
logger.info(f"设置查询日期范围: {start_date} ~ {end_date}") logger.info(f"设置查询日期范围: {start_date} ~ {end_date}")
@@ -117,14 +139,35 @@ class SecsionDownloader:
end_val = await end_input.input_value() end_val = await end_input.input_value()
logger.info(f"日期设置结果: 开始={start_val}, 结束={end_val}") logger.info(f"日期设置结果: 开始={start_val}, 结束={end_val}")
# 等待数据请求完成 # 等待数据请求完成 + 表格渲染(Docker 中增加等待时间)
logger.info("等待数据请求完成...") logger.info("等待数据请求完成...")
await asyncio.sleep(3) await asyncio.sleep(3)
# 如果配置了 shop_id,拦截导出请求注入 shop_id # 检查数据是否加载完成(等待loading消失或有实际数据)
if self.shop_id: try:
import json # 等待加载指示符消失或数据表格出现
await page.wait_for_function(
"""() => {
// 检查是否存在加载中的标志
const loading = document.querySelector('[class*="loading"]');
if (loading && loading.style.display !== 'none') return false;
// 检查是否有数据行
const rows = document.querySelectorAll('table tbody tr');
return rows.length > 0;
}""",
timeout=30000
)
logger.info("数据表格已加载")
except Exception as e:
logger.warning(f"表格加载检查失败: {e},继续执行...")
await asyncio.sleep(3)
# 如果配置了 shop_id,拦截导出请求注入 shop_id,并捕获服务端响应
import json
export_response = {'status': None, 'body': None, 'content_type': None}
if self.shop_id:
async def inject_shop_id(route): async def inject_shop_id(route):
request = route.request request = route.request
body = json.loads(request.post_data) body = json.loads(request.post_data)
@@ -135,10 +178,53 @@ class SecsionDownloader:
await page.route('**/api/bill/export', inject_shop_id) await page.route('**/api/bill/export', inject_shop_id)
logger.info(f"已设置 shop_id 拦截: {self.shop_id}") logger.info(f"已设置 shop_id 拦截: {self.shop_id}")
# 捕获导出接口的响应(用于调试)
async def on_response(response):
if '/api/bill/export' in response.url:
export_response['status'] = response.status
export_response['content_type'] = response.headers.get('content-type', '')
try:
body = await response.text()
export_response['body'] = body[:2000] if body else ''
except Exception:
export_response['body'] = '(binary or empty)'
logger.info(f"导出接口响应: status={response.status}, content-type={export_response['content_type']}, body长度={len(export_response['body'] or '')}")
page.on("response", on_response)
# 监控弹窗(导出可能在新窗口打开下载链接)
popup_pages = []
async def handle_popup(popup):
popup_pages.append(popup)
logger.info(f"检测到弹窗: {popup.url}")
# 弹窗的 SSL 拦截也要处理
await popup.wait_for_load_state("domcontentloaded", timeout=15000)
await self._bypass_ssl_interstitial(popup)
page.on("popup", handle_popup)
# 记录下载目录现有文件(用于兜底检测)
existing_files = set(os.listdir(self.download_dir)) if os.path.exists(self.download_dir) else set()
# 点击导出报表并捕获下载 # 点击导出报表并捕获下载
logger.info("点击导出报表...") logger.info("点击导出报表...")
async with page.expect_download(timeout=60000) as download_info: download_timeout = 120000 # 2 分钟,给 SSL 绕过留足时间
try:
async with page.expect_download(timeout=download_timeout) as download_info:
await export_btn.click() await export_btn.click()
logger.info("等待文件下载中...")
# 等待弹窗出现
await asyncio.sleep(2)
# 先处理主页面 SSL 拦截
await self._bypass_ssl_interstitial(page)
# 再处理所有弹窗
for popup in popup_pages:
await self._bypass_ssl_interstitial(popup)
download = await download_info.value download = await download_info.value
filename = download.suggested_filename filename = download.suggested_filename
@@ -147,6 +233,172 @@ class SecsionDownloader:
logger.info(f"报表已保存至: {save_path}") logger.info(f"报表已保存至: {save_path}")
return save_path return save_path
except Exception as download_err:
logger.warning(f"Playwright 下载事件捕获失败: {download_err}")
# 处理所有已知页面的 SSL 拦截
all_pages = [page] + popup_pages + [p for p in page.context.pages if p != page]
for p in all_pages:
try:
await self._bypass_ssl_interstitial(p)
except Exception:
pass
# 绕过 SSL 后重新等待下载
for p in all_pages:
try:
async with p.expect_download(timeout=15000) as dl_info:
pass
download = await dl_info.value
filename = download.suggested_filename
save_path = os.path.join(self.download_dir, filename)
await download.save_as(save_path)
logger.info(f"SSL 绕过后从页面下载成功: {save_path}")
return save_path
except Exception:
continue
logger.info("尝试文件系统兜底检测...")
# 等待一小段时间让可能的下载完成
await asyncio.sleep(5)
new_files = self._find_new_files(existing_files)
if new_files:
# 按修改时间取最新的
latest = max(new_files, key=lambda f: os.path.getmtime(os.path.join(self.download_dir, f)))
save_path = os.path.join(self.download_dir, latest)
logger.info(f"文件系统兜底检测到新文件: {save_path}")
return save_path
logger.warning("文件系统兜底检测也未发现新文件")
# 保存调试截图
try:
screenshot_path = os.path.join(self.download_dir, f"debug_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png")
await page.screenshot(path=screenshot_path, full_page=True)
logger.info(f"调试截图已保存: {screenshot_path}")
except Exception as ss_err:
logger.warning(f"截图保存失败: {ss_err}")
# 打印捕获到的响应信息
if export_response['status']:
logger.info(f"服务端实际响应: status={export_response['status']}, content-type={export_response['content_type']}")
if export_response['body']:
logger.info(f"响应内容(前500字): {export_response['body'][:500]}")
else:
logger.warning("未捕获到 /api/bill/export 响应,可能是请求被拦截或未发出")
# 检查页面是否有错误提示
try:
error_text = await page.evaluate("""() => {
const msgs = document.querySelectorAll('.t-message--error, .t-notification--error, [class*="error"], .el-message--error');
return Array.from(msgs).map(el => el.textContent.trim()).filter(Boolean).join(' | ');
}""")
if error_text:
logger.error(f"页面错误提示: {error_text}")
except Exception:
pass
# 检查是否有新打开的标签页(某些网站通过 window.open 下载)
try:
pages = page.context.pages
if len(pages) > 1:
logger.info(f"检测到 {len(pages)} 个标签页,检查新标签页...")
for p in pages[1:]:
url = p.url
logger.info(f"新标签页 URL: {url}")
if url.startswith('blob:') or 'download' in url.lower() or 'export' in url.lower():
# 尝试从新标签页下载
try:
async with p.expect_download(timeout=30000) as dl_info:
pass
download = await dl_info.value
filename = download.suggested_filename
save_path = os.path.join(self.download_dir, filename)
await download.save_as(save_path)
logger.info(f"从新标签页下载成功: {save_path}")
return save_path
except Exception:
pass
except Exception:
pass
raise
def _find_new_files(self, existing_files):
"""检测下载目录中新增的文件"""
if not os.path.exists(self.download_dir):
return []
current_files = set(os.listdir(self.download_dir))
new_files = current_files - existing_files
# 过滤掉临时文件和调试截图
return [f for f in new_files if not f.endswith(('.crdownload', '.tmp')) and not f.startswith('debug_')]
async def _bypass_ssl_interstitial(self, page):
"""
绕过 Chrome SSL 证书错误拦截页面
secsion.com 的导出下载链接 SSL 证书过期,Chrome 会弹
"您的连接不是私密连接" 警告页。点 "高级""继续前往"
Returns:
bool: 是否成功绕过(或无需绕过)
"""
try:
await page.wait_for_timeout(2000)
current_url = page.url
logger.debug(f"SSL 绕过检查: 当前 URL={current_url}")
# 检查是否在 SSL 错误页面
is_ssl_error_page = (
'chrome-error' in current_url or
'security' in current_url.lower() or
await page.evaluate(
"""() => {
return document.querySelector('#details-button') !== null ||
document.querySelector('#proceed-link') !== null ||
document.body?.innerText?.includes('您的连接不是私密连接') ||
document.body?.innerText?.includes('NET::ERR_CERT');
}"""
)
)
if not is_ssl_error_page:
return False
logger.info("检测到 SSL 证书错误拦截页面,尝试绕过...")
# 点击 "高级" 按钮展开详情
details_btn = page.locator('#details-button')
if await details_btn.count() > 0:
await details_btn.click()
await page.wait_for_timeout(500)
logger.info("已点击「高级」")
# 点击 "继续前往 xxx(不安全)"
proceed_link = page.locator('#proceed-link')
if await proceed_link.count() > 0:
await proceed_link.click()
await page.wait_for_timeout(2000)
logger.info("已点击「继续前往(不安全)」,SSL 绕过成功")
return True
# 备选:中文按钮文字
unsafe_link = page.get_by_text('继续前往')
if await unsafe_link.count() > 0:
await unsafe_link.click()
await page.wait_for_timeout(2000)
logger.info("已点击「继续前往」,SSL 绕过成功")
return True
logger.warning("SSL 拦截页面检测到但未找到绕过按钮")
return False
except Exception as e:
logger.warning(f"SSL 绕过检查异常: {e}")
return False
async def _set_date(self, page, input_box, date_str): async def _set_date(self, page, input_box, date_str):
""" """
设置 TDesign 日期选择器的值 设置 TDesign 日期选择器的值
@@ -157,12 +409,14 @@ class SecsionDownloader:
3. 在输入框上按 Enter 确认(关键!不确认则关闭时回滚) 3. 在输入框上按 Enter 确认(关键!不确认则关闭时回滚)
4. Escape 关闭日历 4. Escape 关闭日历
""" """
for attempt in range(3): max_attempts = 5
logger.info(f"设置日期: {date_str} (第 {attempt + 1} 次尝试)") for attempt in range(max_attempts):
try:
logger.info(f"设置日期: {date_str} (第 {attempt + 1}/{max_attempts} 次尝试)")
# 1. 点击输入框打开日历 # 1. 点击输入框打开日历
await input_box.click() await input_box.click()
await page.wait_for_timeout(500) await page.wait_for_timeout(800)
# 2. 点击目标日期格子 # 2. 点击目标日期格子
target_day = str(int(date_str.split("-")[2])) target_day = str(int(date_str.split("-")[2]))
@@ -170,19 +424,22 @@ class SecsionDownloader:
cell_count = await day_cells.count() cell_count = await day_cells.count()
if cell_count > 0: if cell_count > 0:
logger.debug(f"找到 {cell_count} 个日期格子,点击第一个")
await day_cells.first.click() await day_cells.first.click()
await page.wait_for_timeout(500) await page.wait_for_timeout(800)
else: else:
logger.warning(f"未找到日期格子: {target_day}") logger.warning(f"未找到日期格子: {target_day},重试...")
await page.keyboard.press("Escape")
await page.wait_for_timeout(500)
continue continue
# 3. Enter 确认(needconfirm="true" 必须显式确认) # 3. Enter 确认(needconfirm="true" 必须显式确认)
await input_box.press("Enter") await input_box.press("Enter")
await page.wait_for_timeout(500) await page.wait_for_timeout(800)
# 4. Escape 关闭日历 # 4. Escape 关闭日历
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await page.wait_for_timeout(500) await page.wait_for_timeout(800)
# 5. 验证 # 5. 验证
val = await input_box.input_value() val = await input_box.input_value()
@@ -190,9 +447,16 @@ class SecsionDownloader:
logger.info(f"日期设置成功: {val}") logger.info(f"日期设置成功: {val}")
return return
logger.warning(f"日期设置验证失败: 期望包含 '{date_str}', 实际 '{val}'") logger.warning(f"日期设置验证失败: 期望包含 '{date_str}', 实际 '{val}',重试...")
await page.wait_for_timeout(500)
logger.error(f"日期设置失败(3次尝试后): {date_str}") except Exception as e:
logger.warning(f"日期设置异常 (第 {attempt + 1}/{max_attempts} 次): {e}")
await page.keyboard.press("Escape")
await page.wait_for_timeout(500)
continue
logger.error(f"日期设置失败({max_attempts}次尝试后): {date_str}")
async def download_report(start_date, end_date, username=None, password=None, download_dir=None, shop_id=None): async def download_report(start_date, end_date, username=None, password=None, download_dir=None, shop_id=None):
+34 -2
View File
@@ -5,10 +5,42 @@ services:
build: . build: .
container_name: saleshow-app container_name: saleshow-app
ports: ports:
- "${APP_PORT:-5000}:5000" # 映射主机端口:容器端口,支持通过 APP_PORT 环境变量修改主机端口 - "${APP_PORT:-5000}:5000"
environment: environment:
- PORT=5000 - PORT=5000
- FLASK_DEBUG=False - FLASK_DEBUG=False
- SECSION_USERNAME=${SECSION_USERNAME:-}
- SECSION_PASSWORD=${SECSION_PASSWORD:-}
- SCHEDULER_ENABLED=${SCHEDULER_ENABLED:-true}
- SCHEDULER_HOUR=${SCHEDULER_HOUR:-1}
- SCHEDULER_MINUTE=${SCHEDULER_MINUTE:-0}
volumes: volumes:
- ./uploads:/app/uploads # 挂载上传目录,持久化数据 - ./uploads:/app/uploads
- ./data:/app/data
- ./downloads:/app/downloads
restart: unless-stopped restart: unless-stopped
# Docker 资源优化
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
# 网络优化
networks:
- default
dns:
- 8.8.8.8
- 1.1.1.1
# 增加日志配置
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
default:
driver: bridge
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Docker 调试脚本 - 用于测试自动下载功能
set -e
echo "🐳 Docker 自动下载调试脚本"
echo "=============================="
# 参数检查
if [ "$#" -lt 3 ]; then
echo "用法: ./docker-debug.sh <username> <password> <start_date> [end_date]"
echo "例如: ./docker-debug.sh 15682076681 123456 2026-05-15 2026-05-17"
exit 1
fi
USERNAME=$1
PASSWORD=$2
START_DATE=$3
END_DATE=${4:-$START_DATE}
echo "📝 参数信息:"
echo " 用户名: $USERNAME"
echo " 起始日期: $START_DATE"
echo " 结束日期: $END_DATE"
echo ""
# 检查容器是否运行
if ! docker ps | grep -q saleshow-app; then
echo "❌ 容器未运行,正在启动..."
docker-compose up -d
echo "⏳ 等待容器启动..."
sleep 5
fi
echo "📊 容器状态:"
docker ps --filter name=saleshow-app --format "table {{.ID}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo "🔍 运行下载测试..."
docker exec saleshow-app python -m automation.secsion \
--username "$USERNAME" \
--password "$PASSWORD" \
--start "$START_DATE" \
--end "$END_DATE"
echo ""
echo "✅ 检查下载结果:"
docker exec saleshow-app ls -lh downloads/
echo ""
echo "📋 最近的日志:"
docker logs --tail 20 saleshow-app
+3
View File
@@ -3,3 +3,6 @@ pandas>=2.0.0
openpyxl>=3.0.0 openpyxl>=3.0.0
xlrd>=2.0.0 xlrd>=2.0.0
Werkzeug>=2.0.0 Werkzeug>=2.0.0
playwright>=1.40.0
apscheduler>=3.10.0
python-dotenv>=1.0.0
+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>