Compare commits

...

2 Commits

10 changed files with 927 additions and 17 deletions
+6
View File
@@ -15,6 +15,12 @@ uploads/
*.xlsx *.xlsx
*.xls *.xls
# 下载的临时文件
downloads/
# 配置数据(含凭据)
data/config.json
# IDE文件 # IDE文件
.vscode/ .vscode/
.idea/ .idea/
+25 -5
View File
@@ -10,8 +10,26 @@ ENV PYTHONDONTWRITEBYTECODE=1
# 确保 Python 输出不被缓冲 # 确保 Python 输出不被缓冲
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# 安装系统依赖(如有需要) # 安装 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 \
&& rm -rf /var/lib/apt/lists/*
# 复制依赖文件 # 复制依赖文件
COPY requirements.txt . COPY requirements.txt .
@@ -20,15 +38,17 @@ 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 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"]
+75 -9
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,21 @@ python app.py
- **清理文件** - 点击"清理文件"按钮可删除所有上传的Excel文件 - **清理文件** - 点击"清理文件"按钮可删除所有上传的Excel文件
- **自动加载** - 页面刷新时自动加载最新上传的文件 - **自动加载** - 页面刷新时自动加载最新上传的文件
### 自动获取数据
1. **配置凭据** - 点击"设置"进入设置页面,填写 secsion.com 账号密码和店铺 ID
2. **手动获取** - 点击"自动获取"按钮,选择日期范围,点击"开始下载"
3. **定时获取** - 在设置页面启用定时任务,系统每日凌晨自动下载前一天数据
4. **CLI 模式** - 命令行运行:
```bash
# 下载指定日期数据
python -m automation.secsion --start 2026-04-28 --end 2026-04-28
# 指定用户名密码
python -m automation.secsion --start 2026-04-28 --username 18190686888 --password yourpassword
```
> **配置优先级**: Web UI 设置页 > 环境变量 (.env) > 默认值
## 🏗️ 部署说明 ## 🏗️ 部署说明
### 开发环境部署 ### 开发环境部署
@@ -125,30 +148,53 @@ 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模板
├── uploads/ # 上传文件存储目录(持久化挂载) │ └── settings.html # 设置页面
── README.md # 项目说明文档 ── uploads/ # 上传文件存储目录(持久化挂载)
├── data/ # 配置数据目录(持久化挂载)
├── downloads/ # 自动下载临时目录
└── README.md # 项目说明文档
``` ```
## 🛠️ 技术栈 ## 🛠️ 技术栈
@@ -158,6 +204,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 +217,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 +262,6 @@ SaleShow/
--- ---
**最后更新时间:** 2026年1月10 **最后更新时间:** 2026年4月29
*享受数据分析的乐趣!📊✨* *享受数据分析的乐趣!📊✨*
+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)
+9 -2
View File
@@ -5,10 +5,17 @@ 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
+4 -1
View File
@@ -2,4 +2,7 @@ Flask>=2.0.0
pandas>=2.0.0 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>