Compare commits
8 Commits
89b01bb522
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6f81b6f6e | |||
| 70f293db96 | |||
| e95a1723e5 | |||
| b402612641 | |||
| 975f9e5887 | |||
| 505e5ca895 | |||
| 4132226fae | |||
| 75bdc94cfe |
@@ -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
|
||||
@@ -15,6 +15,12 @@ uploads/
|
||||
*.xlsx
|
||||
*.xls
|
||||
|
||||
# 下载的临时文件
|
||||
downloads/
|
||||
|
||||
# 配置数据(含凭据)
|
||||
data/config.json
|
||||
|
||||
# IDE文件
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -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
@@ -9,9 +9,30 @@ WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
# 确保 Python 输出不被缓冲
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
# 禁用 Python 的硬件指令集检查,提高Chromium兼容性
|
||||
ENV PYTHONHASHSEED=0
|
||||
|
||||
# 安装系统依赖(如有需要)
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
|
||||
# 安装 Playwright 所需的系统依赖 + 网络优化
|
||||
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 .
|
||||
@@ -20,15 +41,18 @@ COPY requirements.txt .
|
||||
# 使用阿里云镜像源加速
|
||||
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
|
||||
COPY . .
|
||||
|
||||
# 创建上传目录(确保权限)
|
||||
RUN mkdir -p uploads && chmod 777 uploads
|
||||
# 创建上传目录和数据目录(确保权限)
|
||||
RUN mkdir -p uploads data downloads && chmod 777 uploads data downloads
|
||||
|
||||
# 暴露端口 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# 增加容器内存限制和进程管理
|
||||
# 运行 app.py
|
||||
# 生产环境建议使用 gunicorn (需添加到 requirements.txt),但这里为了简单直接运行 python app.py
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
@@ -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文件
|
||||
- **自动加载** - 页面刷新时自动加载最新上传的文件
|
||||
|
||||
### 自动获取数据
|
||||
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
|
||||
# 设置Flask环境
|
||||
export FLASK_DEBUG=False
|
||||
# 设置端口 (手动运行 python app.py 时生效,Docker使用映射端口)
|
||||
export PORT=5000
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
主要配置项:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `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/
|
||||
├── app.py # Flask应用主文件
|
||||
├── config.py # 配置管理模块
|
||||
├── automation/ # 自动化模块
|
||||
│ ├── __init__.py
|
||||
│ ├── secsion.py # secsion.com 登录+导出逻辑
|
||||
│ ├── uploader.py # 本地文件导入
|
||||
│ └── scheduler.py # APScheduler 定时任务
|
||||
├── requirements.txt # Python依赖包列表
|
||||
├── Dockerfile # Docker构建文件
|
||||
├── docker-compose.yml # Docker Compose配置
|
||||
├── .env.example # 环境变量配置示例
|
||||
├── static/ # 静态资源目录
|
||||
│ ├── css/
|
||||
│ │ └── style.css # 样式文件
|
||||
│ └── js/
|
||||
│ └── main.js # 交互逻辑
|
||||
├── templates/
|
||||
│ └── index.html # 前端HTML模板
|
||||
│ ├── index.html # 前端HTML模板
|
||||
│ └── settings.html # 设置页面
|
||||
├── uploads/ # 上传文件存储目录(持久化挂载)
|
||||
├── data/ # 配置数据目录(持久化挂载)
|
||||
├── downloads/ # 自动下载临时目录
|
||||
└── README.md # 项目说明文档
|
||||
```
|
||||
|
||||
@@ -158,6 +211,9 @@ SaleShow/
|
||||
- **pandas** 2.3.3 - 数据处理
|
||||
- **openpyxl** 3.1.5 - Excel文件处理
|
||||
- **xlrd** 2.0.1 - 旧版Excel文件支持
|
||||
- **Playwright** - 浏览器自动化(secsion.com 数据获取)
|
||||
- **APScheduler** - 定时任务调度
|
||||
- **python-dotenv** - 环境变量管理
|
||||
|
||||
### 前端
|
||||
- **HTML5** - 页面结构
|
||||
@@ -168,6 +224,23 @@ SaleShow/
|
||||
### 开发工具
|
||||
- **Git** - 版本控制
|
||||
- **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日
|
||||
|
||||
*享受数据分析的乐趣!📊✨*
|
||||
|
||||
@@ -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)
|
||||
+294
-30
@@ -25,24 +25,39 @@ class SecsionDownloader:
|
||||
self.download_dir = download_dir or os.path.join(os.getcwd(), "downloads")
|
||||
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:
|
||||
start_date: 开始日期 (YYYY-MM-DD)
|
||||
end_date: 结束日期 (YYYY-MM-DD)
|
||||
retry_count: 重试次数(默认3次)
|
||||
|
||||
Returns:
|
||||
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:
|
||||
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(
|
||||
ignore_https_errors=True,
|
||||
viewport={'width': 1280, 'height': 800}
|
||||
viewport={'width': 1280, 'height': 800},
|
||||
accept_downloads=True
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
@@ -51,23 +66,30 @@ class SecsionDownloader:
|
||||
file_path = await self._export_report(page, start_date, end_date)
|
||||
logger.info(f"报表下载完成: {file_path}")
|
||||
return file_path
|
||||
except Exception as e:
|
||||
logger.error(f"下载报表失败: {e}")
|
||||
return None
|
||||
finally:
|
||||
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):
|
||||
"""登录 secsion.com"""
|
||||
logger.info(f"打开登录页面: {self.LOGIN_URL}")
|
||||
await page.goto(self.LOGIN_URL)
|
||||
await page.goto(self.LOGIN_URL, timeout=30000)
|
||||
|
||||
# 选择角色 "店铺"
|
||||
logger.info("选择角色: 店铺")
|
||||
try:
|
||||
await page.get_by_text("店铺", exact=True).click()
|
||||
await page.get_by_text("店铺", exact=True).click(timeout=10000)
|
||||
except Exception:
|
||||
await page.click("text=店铺")
|
||||
await page.click("text=店铺", timeout=10000)
|
||||
|
||||
# 输入账号密码
|
||||
logger.info(f"输入账号: {self.username}")
|
||||
@@ -83,21 +105,21 @@ class SecsionDownloader:
|
||||
try:
|
||||
await page.click("button:has-text('登录')", timeout=5000)
|
||||
except Exception:
|
||||
await page.click("button[type='submit']")
|
||||
await page.click("button[type='submit']", timeout=5000)
|
||||
|
||||
# 等待跳转
|
||||
# 等待跳转(Docker 中需要更长时间)
|
||||
logger.info("等待登录跳转...")
|
||||
await page.wait_for_url("**/homePage", timeout=20000)
|
||||
await page.wait_for_url("**/homePage", timeout=30000)
|
||||
logger.info("登录成功")
|
||||
|
||||
async def _export_report(self, page, start_date, end_date):
|
||||
"""访问统计页面并导出报表"""
|
||||
logger.info(f"访问统计页面: {self.STATS_URL}")
|
||||
await page.goto(self.STATS_URL)
|
||||
await page.wait_for_load_state("networkidle")
|
||||
await page.goto(self.STATS_URL, timeout=30000)
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
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}")
|
||||
|
||||
@@ -117,14 +139,35 @@ class SecsionDownloader:
|
||||
end_val = await end_input.input_value()
|
||||
logger.info(f"日期设置结果: 开始={start_val}, 结束={end_val}")
|
||||
|
||||
# 等待数据请求完成
|
||||
# 等待数据请求完成 + 表格渲染(Docker 中增加等待时间)
|
||||
logger.info("等待数据请求完成...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 如果配置了 shop_id,拦截导出请求注入 shop_id
|
||||
if self.shop_id:
|
||||
import json
|
||||
# 检查数据是否加载完成(等待loading消失或有实际数据)
|
||||
try:
|
||||
# 等待加载指示符消失或数据表格出现
|
||||
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):
|
||||
request = route.request
|
||||
body = json.loads(request.post_data)
|
||||
@@ -135,10 +178,53 @@ class SecsionDownloader:
|
||||
await page.route('**/api/bill/export', inject_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("点击导出报表...")
|
||||
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()
|
||||
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
|
||||
filename = download.suggested_filename
|
||||
@@ -147,6 +233,172 @@ class SecsionDownloader:
|
||||
logger.info(f"报表已保存至: {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):
|
||||
"""
|
||||
设置 TDesign 日期选择器的值
|
||||
@@ -157,12 +409,14 @@ class SecsionDownloader:
|
||||
3. 在输入框上按 Enter 确认(关键!不确认则关闭时回滚)
|
||||
4. Escape 关闭日历
|
||||
"""
|
||||
for attempt in range(3):
|
||||
logger.info(f"设置日期: {date_str} (第 {attempt + 1} 次尝试)")
|
||||
max_attempts = 5
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
logger.info(f"设置日期: {date_str} (第 {attempt + 1}/{max_attempts} 次尝试)")
|
||||
|
||||
# 1. 点击输入框打开日历
|
||||
await input_box.click()
|
||||
await page.wait_for_timeout(500)
|
||||
await page.wait_for_timeout(800)
|
||||
|
||||
# 2. 点击目标日期格子
|
||||
target_day = str(int(date_str.split("-")[2]))
|
||||
@@ -170,19 +424,22 @@ class SecsionDownloader:
|
||||
cell_count = await day_cells.count()
|
||||
|
||||
if cell_count > 0:
|
||||
logger.debug(f"找到 {cell_count} 个日期格子,点击第一个")
|
||||
await day_cells.first.click()
|
||||
await page.wait_for_timeout(500)
|
||||
await page.wait_for_timeout(800)
|
||||
else:
|
||||
logger.warning(f"未找到日期格子: {target_day}")
|
||||
logger.warning(f"未找到日期格子: {target_day},重试...")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.wait_for_timeout(500)
|
||||
continue
|
||||
|
||||
# 3. Enter 确认(needconfirm="true" 必须显式确认)
|
||||
await input_box.press("Enter")
|
||||
await page.wait_for_timeout(500)
|
||||
await page.wait_for_timeout(800)
|
||||
|
||||
# 4. Escape 关闭日历
|
||||
await page.keyboard.press("Escape")
|
||||
await page.wait_for_timeout(500)
|
||||
await page.wait_for_timeout(800)
|
||||
|
||||
# 5. 验证
|
||||
val = await input_box.input_value()
|
||||
@@ -190,9 +447,16 @@ class SecsionDownloader:
|
||||
logger.info(f"日期设置成功: {val}")
|
||||
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):
|
||||
|
||||
+34
-2
@@ -5,10 +5,42 @@ services:
|
||||
build: .
|
||||
container_name: saleshow-app
|
||||
ports:
|
||||
- "${APP_PORT:-5000}:5000" # 映射主机端口:容器端口,支持通过 APP_PORT 环境变量修改主机端口
|
||||
- "${APP_PORT:-5000}:5000"
|
||||
environment:
|
||||
- PORT=5000
|
||||
- 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:
|
||||
- ./uploads:/app/uploads # 挂载上传目录,持久化数据
|
||||
- ./uploads:/app/uploads
|
||||
- ./data:/app/data
|
||||
- ./downloads:/app/downloads
|
||||
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
|
||||
|
||||
@@ -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,3 +3,6 @@ pandas>=2.0.0
|
||||
openpyxl>=3.0.0
|
||||
xlrd>=2.0.0
|
||||
Werkzeug>=2.0.0
|
||||
playwright>=1.40.0
|
||||
apscheduler>=3.10.0
|
||||
python-dotenv>=1.0.0
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = '<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) {
|
||||
elements.loadingOverlay.style.display = show ? 'flex' : 'none';
|
||||
if (text) elements.loadingText.textContent = text;
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
<button class="btn btn-primary btn-lg btn-block" onclick="openUploadModal()">
|
||||
<i class="fas fa-plus-circle"></i> 上传新文件
|
||||
</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;">
|
||||
<span class="label">当前分析:</span>
|
||||
@@ -130,6 +136,42 @@
|
||||
</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 class="loading-box">
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user