commit fd8f6f7a02fc0c26aba0d3cce5ef5da50d40cd38 Author: houhuan Date: Sat Jan 10 18:54:14 2026 +0800 Feat: Complete UI redesign, backend optimization, and Docker deployment support diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a787ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# 虚拟环境 +.venv/ +venv/ +env/ +ENV/ + +# Python缓存文件 +__pycache__/ +*.py[cod] +*$py.class +*.so + +# 上传的文件 +uploads/ +*.xlsx +*.xls + +# IDE文件 +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 操作系统文件 +.DS_Store +Thumbs.db + +# 日志文件 +*.log + +# 临时文件 +*.tmp +*.temp + +# 环境变量文件 +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# 部署相关 +.vercel/ +dist/ +build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9189bb3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# 使用官方 Python 运行时作为父镜像 +FROM python:3.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +# 防止 Python 生成 .pyc 文件 +ENV PYTHONDONTWRITEBYTECODE=1 +# 确保 Python 输出不被缓冲 +ENV PYTHONUNBUFFERED=1 + +# 安装系统依赖(如有需要) +# RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 安装 Python 依赖 +# 使用阿里云镜像源加速 +RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ + +# 复制当前目录内容到容器中的 /app +COPY . . + +# 创建上传目录(确保权限) +RUN mkdir -p uploads && chmod 777 uploads + +# 暴露端口 5000 +EXPOSE 5000 + +# 运行 app.py +# 生产环境建议使用 gunicorn (需添加到 requirements.txt),但这里为了简单直接运行 python app.py +CMD ["python", "app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d5fe1f --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# 📊 销售数据分析器 + +一个现代化的Web应用程序,用于分析和可视化销售数据。支持Excel文件上传,自动解析销售数据,提供丰富的分析功能和美观的界面展示。 + +## ✨ 功能特性 + +### 🎯 核心功能 +- **Excel文件上传** - 支持.xlsx和.xls格式文件 +- **智能数据解析** - 自动识别销售数据结构 +- **多文件管理** - 支持多个Excel文件的切换查看 +- **数据筛选** - 按金额范围进行数据筛选 +- **搜索功能** - 支持商品名称搜索 + +### 📊 数据展示 +- **销售总览** - 显示总销售额、销售天数、商品种类等统计信息 +- **每日详情** - 按日期分组展示销售数据 +- **双视图模式** - 支持卡片视图和表格视图切换 +- **表格排序** - 点击表头实现数据排序(升序/降序/原始顺序) + +### 🎨 用户界面 +- **现代化设计** - 采用渐变色彩和卡片式布局 +- **响应式设计** - 完美适配桌面和移动设备 +- **专业图标** - 使用Font Awesome图标系统 +- **加载优化** - 骨架屏和进度条提升用户体验 + +### 🔧 技术特性 +- **高性能** - 基于Flask框架,快速响应 +- **安全性** - 文件上传安全验证和清理功能 +- **可扩展** - 模块化设计,易于扩展新功能 + +## 🚀 快速开始 + +### 系统要求 + +- **Python** 3.8+ +- **操作系统** Windows/Linux/macOS +- **浏览器** 现代浏览器(Chrome、Firefox、Safari、Edge) + +### 安装步骤 + +#### 1. 克隆项目 +```bash +git clone https://gitea.94kan.cn/houhuan/SaleShow.git +cd SaleShow +``` + +#### 2. 创建虚拟环境(推荐) +```bash +# Windows +python -m venv venv +venv\Scripts\activate + +# Linux/macOS +python -m venv venv +source venv/bin/activate +``` + +#### 3. 安装依赖 +```bash +pip install -r requirements.txt +``` + +#### 4. 运行应用 +```bash +python app.py +``` + +#### 5. 访问应用 +打开浏览器,访问:http://localhost:5000 + +## 📖 使用指南 + +### 上传文件 +1. 点击"上传Excel文件"按钮 +2. 选择.xlsx或.xls格式的Excel文件 +3. 等待文件处理完成 +4. 查看分析结果 + +### 查看数据 +- **左侧面板** - 显示已上传的文件列表,点击可切换查看不同文件 +- **顶部统计** - 显示销售总览信息 +- **每日数据** - 按日期分组展示详细销售记录 + +### 数据操作 +- **视图切换** - 点击日期卡片右上角的图标可在卡片视图和表格视图间切换 +- **表格排序** - 在表格视图中点击列标题可进行排序 +- **数据筛选** - 使用底部的筛选按钮按金额范围筛选数据 +- **搜索功能** - 在搜索框中输入商品名称进行搜索 + +### 文件管理 +- **清理文件** - 点击"清理文件"按钮可删除所有上传的Excel文件 +- **自动加载** - 页面刷新时自动加载最新上传的文件 + +## 🏗️ 部署说明 + +### 开发环境部署 +按照"快速开始"部分的步骤即可。 + +### 生产环境部署 + +#### 使用Gunicorn(推荐) +```bash +pip install gunicorn +gunicorn -w 4 -b 0.0.0.0:8000 app:app +``` + +#### 使用Docker部署 +```dockerfile +FROM python:3.9-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +EXPOSE 5000 + +CMD ["python", "app.py"] +``` + +#### Nginx反向代理配置 +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 环境变量配置 +```bash +# 设置Flask环境 +export FLASK_ENV=production +export FLASK_DEBUG=False + +# 设置上传文件大小限制(默认16MB) +export MAX_CONTENT_LENGTH=16777216 +``` + +## 📁 项目结构 + +``` +SaleShow/ +├── app.py # Flask应用主文件 +├── requirements.txt # Python依赖包列表 +├── templates/ +│ └── index.html # 前端HTML模板 +├── uploads/ # 上传文件存储目录(自动创建) +├── __pycache__/ # Python缓存文件 +└── README.md # 项目说明文档 +``` + +## 🛠️ 技术栈 + +### 后端 +- **Flask** 2.3.3 - Web框架 +- **pandas** 2.3.3 - 数据处理 +- **openpyxl** 3.1.5 - Excel文件处理 +- **xlrd** 2.0.1 - 旧版Excel文件支持 + +### 前端 +- **HTML5** - 页面结构 +- **CSS3** - 样式设计 +- **JavaScript (ES6+)** - 交互功能 +- **Font Awesome 6.4.0** - 图标库 + +### 开发工具 +- **Git** - 版本控制 +- **pip** - 包管理 + +## 🔒 安全说明 + +- 文件上传仅支持.xlsx和.xls格式 +- 文件大小限制为16MB +- 使用安全的文件名处理 +- 定期清理上传的文件以释放磁盘空间 + +## 🤝 贡献指南 + +1. Fork 本项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建 Pull Request + +## 📄 许可证 + +本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 + +## 📞 联系方式 + +如有问题或建议,请通过以下方式联系: +- 项目地址:https://gitea.94kan.cn/houhuan/SaleShow +- 提交 Issue:项目仓库的Issues页面 + +--- + +**最后更新时间:** 2026年1月3日 + +*享受数据分析的乐趣!📊✨* diff --git a/app.py b/app.py new file mode 100644 index 0000000..4d67c30 --- /dev/null +++ b/app.py @@ -0,0 +1,401 @@ +from flask import Flask, render_template, request, jsonify, send_from_directory +import pandas as pd +import os +from werkzeug.utils import secure_filename +import json +from datetime import datetime +import glob +import time + +app = Flask(__name__) +app.config['UPLOAD_FOLDER'] = 'uploads' +app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size + +# 确保上传文件夹存在 +if not os.path.exists(app.config['UPLOAD_FOLDER']): + os.makedirs(app.config['UPLOAD_FOLDER']) + +ALLOWED_EXTENSIONS = {'xlsx', 'xls'} + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/files') +def get_files(): + """获取已上传的文件列表""" + try: + upload_folder = app.config['UPLOAD_FOLDER'] + if not os.path.exists(upload_folder): + return jsonify({'files': []}) + + files = [] + # 使用glob获取所有Excel文件 + all_files = glob.glob(os.path.join(upload_folder, '*.xlsx')) + glob.glob(os.path.join(upload_folder, '*.xls')) + + for filepath in all_files: + if not os.path.isfile(filepath): + continue + + filename = os.path.basename(filepath) + # 解析时间戳和原始文件名 + try: + # 尝试解析格式: YYYYMMDD_HHMMSS_original_filename + parts = filename.split('_', 2) + if len(parts) >= 3: + timestamp_str = parts[0] + parts[1] + upload_time = datetime.strptime(timestamp_str, '%Y%m%d%H%M%S') + original_name = parts[2] + else: + upload_time = datetime.fromtimestamp(os.path.getmtime(filepath)) + original_name = filename + except: + upload_time = datetime.fromtimestamp(os.path.getmtime(filepath)) + original_name = filename + + file_size = os.path.getsize(filepath) + + files.append({ + 'filename': filename, + 'original_name': original_name, + 'upload_time': upload_time.strftime('%Y-%m-%d %H:%M:%S'), + 'file_size': file_size, + 'file_size_human': f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB" + }) + + files.sort(key=lambda x: x['upload_time'], reverse=True) + return jsonify({'files': files}) + except Exception as e: + return jsonify({'error': f'获取文件列表失败: {str(e)}'}), 500 + +@app.route('/load/') +def load_file(filename): + """加载指定的文件""" + try: + upload_folder = app.config['UPLOAD_FOLDER'] + filepath = os.path.join(upload_folder, filename) + + if not os.path.exists(filepath): + return jsonify({'error': '文件不存在'}), 404 + + if not allowed_file(filename): + return jsonify({'error': '不支持的文件格式'}), 400 + + # 动态查找表头 + header_row = find_header_row(filepath) + df = pd.read_excel(filepath, header=header_row) + sales_data = process_sales_data(df) + + return jsonify({ + 'success': True, + 'filename': filename, + 'data': sales_data + }) + except Exception as e: + return jsonify({'error': f'文件加载错误: {str(e)}'}), 500 + +@app.route('/upload', methods=['POST']) +def upload_file(): + if 'file' not in request.files: + return jsonify({'error': '没有选择文件'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': '没有选择文件'}), 400 + + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_') + filename = timestamp + filename + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file.save(filepath) + + try: + # 动态查找表头 + header_row = find_header_row(filepath) + df = pd.read_excel(filepath, header=header_row) + sales_data = process_sales_data(df) + + return jsonify({ + 'success': True, + 'filename': filename, + 'data': sales_data + }) + except Exception as e: + return jsonify({'error': f'文件处理错误: {str(e)}'}), 500 + + return jsonify({'error': '不支持的文件格式'}), 400 + +@app.route('/cleanup', methods=['POST']) +def cleanup_files(): + """清理上传的文件(立即清理)""" + try: + upload_folder = app.config['UPLOAD_FOLDER'] + if not os.path.exists(upload_folder): + return jsonify({'success': True, 'message': '无需清理'}) + + files = os.listdir(upload_folder) + deleted_count = 0 + errors = [] + + for filename in files: + file_path = os.path.join(upload_folder, filename) + if os.path.isfile(file_path): # Remove extension check to clean everything + try: + os.remove(file_path) + deleted_count += 1 + except PermissionError: + errors.append(f"{filename} 正在被占用,无法删除") + except Exception as e: + errors.append(f"{filename} 删除失败: {str(e)}") + + message = f'成功清理 {deleted_count} 个文件' + if errors: + message += f'。主要错误: {"; ".join(errors[:3])}' + if len(errors) > 3: + message += f" 等共{len(errors)}个错误" + + return jsonify({ + 'success': True, + 'message': message, + 'deleted_count': deleted_count + }) + + except Exception as e: + return jsonify({'error': f'清理文件失败: {str(e)}'}), 500 + +def find_header_row(filepath): + """查找表头所在的行索引""" + try: + # 只读取前20行,不带表头 + df_temp = pd.read_excel(filepath, header=None, nrows=20) + + keywords = ['时间', '日期', '商品', '品名', '数量', '金额', '总价', 'Date', 'Product', 'Qty', 'Amount'] + + for index, row in df_temp.iterrows(): + # 将行转换为字符串列表 + row_str = " ".join([str(val) for val in row.values]) + # 统计包含的关键字数量 + match_count = sum(1 for keyword in keywords if keyword in row_str) + + # 如果一行包含至少2个关键字,认为是表头 + if match_count >= 2: + return index + return 0 # 默认第一行 + except: + return 0 + +def process_sales_data(df): + """处理销售数据,使用Pandas向量化操作""" + try: + # 1. 智能识别列名 + cols = df.columns.tolist() + col_map = {} + + # 优先级关键字 + keywords = { + 'date': ['时间', '日期', 'Time', 'Date'], + 'product': ['商品', '品名', '详情', 'Product', 'Name', 'Description'], + 'quantity': ['数量', '件数', 'Quantity', 'Qty', 'Count'], + 'amount': ['金额', '总价', 'Amount', 'Price', 'Total'], + 'code': ['编码', '货号', '代码', 'Code', 'No'] + } + + for key, priority_words in keywords.items(): + for word in priority_words: + found = False + for col in cols: + if word in str(col): + col_map[key] = col + found = True + break # Found highest priority match + if found: break + + # 兼容旧逻辑的后备方案:按索引 + if 'date' not in col_map and len(cols) > 1: col_map['date'] = cols[1] + if 'product' not in col_map and len(cols) > 2: col_map['product'] = cols[2] + if 'quantity' not in col_map and len(cols) > 3: col_map['quantity'] = cols[3] + if 'amount' not in col_map and len(cols) > 4: col_map['amount'] = cols[4] + if 'code' not in col_map and len(cols) > 0: col_map['code'] = cols[0] + + if not all(k in col_map for k in ['date', 'product', 'quantity', 'amount']): + # 尝试更宽迷糊的匹配 + if len(cols) >= 5: + col_map['code'] = cols[0] + col_map['date'] = cols[1] + col_map['product'] = cols[2] + col_map['quantity'] = cols[3] + col_map['amount'] = cols[4] + else: + raise Exception("无法识别必要的列(时间、商品、数量、金额),请检查Excel格式或列名") + + # 2. 重命名列以便处理 + rename_dict = { + col_map['date']: 'date', + col_map['product']: 'product', + col_map['quantity']: 'quantity', + col_map['amount']: 'amount' + } + if 'code' in col_map: + rename_dict[col_map['code']] = 'code' + + df = df.rename(columns=rename_dict) + + # 确保code列存在 + if 'code' not in df.columns: + df['code'] = '' + + # 3. 数据清洗和类型转换 + for col in ['quantity', 'amount']: + df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0) + + # 4. 日期处理 + df['parsed_date'] = pd.to_datetime(df['date'], errors='coerce') + + # 5. 基于"ID块"的解析逻辑 (State Machine) + + processed_data = [] + daily_summary = {} + + def get_day_entry(date_str): + if date_str not in daily_summary: + daily_summary[date_str] = { + 'date': date_str, + 'total_quantity': 0, + 'total_amount': 0, + 'products': [], + 'summary_info': None + } + return daily_summary[date_str] + + # 状态变量 + current_context = { + 'date_str': None, + 'code': None, + 'header_quantity': 0, # Header行可能包含的总数(参考用) + 'header_amount': 0 + } + + for index, row in df.iterrows(): + # 检查当前行是否有ID (Code) + has_code = pd.notna(row['code']) and str(row['code']).strip() != '' + + # 检查当前行是否有商品名 + has_product = pd.notna(row['product']) and str(row['product']).strip() != '' + + # --- 状态更新逻辑 --- + if has_code: + # 这是一个新的"块"的开始 (Header行) + if pd.notna(row['parsed_date']): + current_context['date_str'] = row['parsed_date'].strftime('%Y-%m-%d %H:%M:%S') + else: + # 如果有Code但没日期,可能沿用上一个?或者这还是同一个块? + # 假设Code行必须有日期,如果没有,可能是数据问题,这里保留上一个日期比较安全 + pass + + current_context['code'] = str(row['code']) + current_context['header_quantity'] = float(row['quantity']) + current_context['header_amount'] = float(row['amount']) + + # 如果这个Header行本身没有任何明细行为(Quantity/Amount都在Header上), + # 层级表里,Header只有总汇。扁平表里,Header也是明细。 + # 通过 has_product 区分: + # 扁平表Row: Code(Yes) + Product(Yes) + # 层级表Header: Code(Yes) + Product(No) + + # --- 记录处理逻辑 --- + + # 如果没有有效日期上下文,无法归档,跳过 + if not current_context['date_str']: + continue + + date_str = current_context['date_str'] + code = current_context['code'] + + if has_product: + # -> 明细记录 (可能是扁平表的当前行,或者是层级表的子行) + product_name = str(row['product']).strip() + quantity = float(row['quantity']) + amount = float(row['amount']) + + # 计算单价 + price = amount / quantity if quantity > 0 else 0 + + # 更新统计 + entry = get_day_entry(date_str) + entry['total_quantity'] += quantity + entry['total_amount'] += amount + + product_info = { + 'product': product_name, + 'quantity': quantity, + 'amount': amount, + 'price': price, + 'code': code + } + entry['products'].append(product_info) + + processed_data.append({ + 'date': date_str, + 'product': product_name, + 'quantity': quantity, + 'amount': amount, + 'price': price, + 'is_summary': False, + 'code': code + }) + + elif has_code and not has_product: + # -> 纯Header行 (层级表结构) + # 我们记录它的汇总信息作为参考,但不计入 daily_summary 的累加(除非完全没有明细) + # 但为了显示在列表中(如果需要显示总汇行),我们可以加一个特殊条目 + + # 只有当包含数值时才记录 + if row['quantity'] > 0 or row['amount'] > 0: + entry = get_day_entry(date_str) + entry['summary_info'] = { + 'total_quantity': float(row['quantity']), + 'total_amount': float(row['amount']), + 'code': code + } + + processed_data.append({ + 'date': date_str, + 'product': '【时间段总计】', + 'quantity': float(row['quantity']), + 'amount': float(row['amount']), + 'is_summary': True, + 'code': code + }) + + # 后处理:如果某些天完全没有明细行,但有summary_info,则使用summary_info填充total + for date_str, entry in daily_summary.items(): + if entry['total_quantity'] == 0 and entry['total_amount'] == 0 and entry['summary_info']: + entry['total_quantity'] = entry['summary_info']['total_quantity'] + entry['total_amount'] = entry['summary_info']['total_amount'] + + return { + 'columns': { + 'code': col_map.get('code', ''), + 'date': col_map['date'], + 'product': col_map['product'], + 'quantity': col_map['quantity'], + 'amount': col_map['amount'] + }, + 'raw_data': processed_data, + 'daily_summary': list(daily_summary.values()) + } + + except Exception as e: + import traceback + traceback.print_exc() + raise Exception(f"数据处理失败: {str(e)}") + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + # 生产环境建议关闭 debug + debug_mode = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true' + app.run(debug=debug_mode, host='0.0.0.0', port=port) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..008ce02 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + saleshow: + build: . + container_name: saleshow-app + ports: + - "${APP_PORT:-5000}:5000" # 映射主机端口:容器端口,支持通过 APP_PORT 环境变量修改主机端口 + environment: + - PORT=5000 + - FLASK_DEBUG=False + volumes: + - ./uploads:/app/uploads # 挂载上传目录,持久化数据 + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..93552b6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask>=2.0.0 +pandas>=2.0.0 +openpyxl>=3.0.0 +xlrd>=2.0.0 +Werkzeug>=2.0.0 \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..407b6f1 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,646 @@ +/* Modern Color Palette & Reset */ +:root { + --primary-color: #6366f1; + /* Indigo */ + --secondary-color: #8b5cf6; + /* Violet */ + --accent-color: #ec4899; + /* Pink */ + --bg-color: #f8fafc; + --text-primary: #1e293b; + --text-secondary: #64748b; + --card-bg: rgba(255, 255, 255, 0.9); + --glass-border: 1px solid rgba(255, 255, 255, 0.4); + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --radius-lg: 16px; + --radius-md: 12px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg-color); + background-image: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%), + radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.15) 0px, transparent 50%); + background-attachment: fixed; + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; +} + +/* Glassmorphism Utilities */ +.glass-card { + background: var(--card-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: var(--glass-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +/* Layout */ +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +.main-header { + text-align: center; + padding: 40px 0 30px; +} + +.logo { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--primary-color); + margin-bottom: 8px; +} + +.logo i { + font-size: 28px; +} + +.logo h1 { + font-size: 28px; + font-weight: 800; + letter-spacing: -0.5px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; +} + +/* Upload Section */ +.upload-section { + padding: 24px; + margin-bottom: 24px; + transition: transform 0.3s ease; +} + +.upload-section:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.section-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; +} + +.section-header h2 i { + color: var(--accent-color); +} + +.upload-controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.btn { + border: none; + cursor: pointer; + font-weight: 600; + font-size: 14px; + border-radius: 50px; + padding: 10px 20px; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn-lg { + padding: 12px 28px; + font-size: 15px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + color: white; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} + +.btn-primary:active { + transform: scale(0.98); +} + +.btn-text { + background: transparent; + color: var(--text-secondary); + padding: 8px 12px; +} + +.btn-text:hover { + color: var(--accent-color); + background: rgba(236, 72, 153, 0.05); +} + +.btn-outline { + border: 1px solid #e2e8f0; + background: white; + color: var(--text-secondary); +} + +.btn-outline:hover { + border-color: var(--primary-color); + color: var(--primary-color); +} + +/* File List Tags */ +.file-selector-wrapper { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + overflow-x: auto; + padding-bottom: 4px; +} + +.file-selector-wrapper .label { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; +} + +.file-tags { + display: flex; + gap: 8px; +} + +.file-tag { + font-size: 12px; + padding: 4px 12px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid #e2e8f0; + border-radius: 20px; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; +} + +.file-tag.active { + background: rgba(99, 102, 241, 0.1); + color: var(--primary-color); + border-color: rgba(99, 102, 241, 0.3); + font-weight: 500; +} + +/* Dashboard Grid */ +.summary-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: white; + padding: 20px; + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + gap: 16px; + border: 1px solid #f1f5f9; +} + +.icon-circle { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.total-sales .icon-circle { + background: rgba(99, 102, 241, 0.1); + color: var(--primary-color); +} + +.total-count .icon-circle { + background: rgba(236, 72, 153, 0.1); + color: var(--accent-color); +} + +.total-days .icon-circle { + background: rgba(16, 185, 129, 0.1); + color: #10b981; +} + +.total-products .icon-circle { + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; +} + +.stat-content { + display: flex; + flex-direction: column; +} + +.stat-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.stat-value { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +/* Toolbar */ +.toolbar { + padding: 12px 16px; + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.search-wrapper { + position: relative; + flex: 1; + min-width: 200px; +} + +.search-wrapper i { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + font-size: 14px; +} + +.search-wrapper input { + width: 100%; + padding: 10px 10px 10px 36px; + border: 1px solid #e2e8f0; + border-radius: 20px; + background: white; + font-size: 14px; + transition: all 0.2s; +} + +.search-wrapper input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1); +} + +.filter-group { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; +} + +.filter-chip { + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; +} + +.filter-chip.active { + background: white; + color: var(--primary-color); + box-shadow: var(--shadow-sm); + font-weight: 600; +} + +/* Daily List */ +.day-card { + margin-bottom: 16px; + border: 1px solid rgba(255, 255, 255, 0.5); + overflow: hidden; +} + +.day-header { + padding: 16px 20px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + background: rgba(255, 255, 255, 0.4); +} + +.day-info { + display: flex; + flex-direction: row; + /* Changed from column */ + align-items: center; + /* Align vertically */ + gap: 12px; +} + +.day-info .date { + font-weight: 600; + color: var(--text-primary); + font-size: 15px; +} + +.day-info .summary { + font-size: 12px; + color: var(--text-secondary); +} + +.toggle-icon { + transition: transform 0.3s; + color: var(--text-secondary); + font-size: 12px; +} + +.day-card.expanded .toggle-icon { + transform: rotate(180deg); +} + +.day-details { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; + background: rgba(255, 255, 255, 0.3); +} + +.day-card.expanded .day-details { + max-height: 2000px; +} + +.product-item { + padding: 12px 20px; + border-top: 1px solid rgba(226, 232, 240, 0.6); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; +} + +.p-name { + font-weight: 500; + color: var(--text-primary); + flex: 1; + padding-right: 12px; +} + +.p-meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--text-secondary); +} + +.p-price { + font-size: 12px; + color: #94a3b8; +} + +.p-total { + font-weight: 600; + color: var(--primary-color); + min-width: 60px; + text-align: right; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.illustration { + font-size: 64px; + color: #cbd5e1; + margin-bottom: 16px; +} + +.empty-state h3 { + color: var(--text-primary); + margin-bottom: 8px; +} + +/* Modals & Overlays */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; +} + +.modal-overlay.active { + opacity: 1; + pointer-events: auto; +} + +.modal-card { + background: white; + width: 90%; + max-width: 400px; + border-radius: 24px; + padding: 24px; + transform: scale(0.9); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.modal-overlay.active .modal-card { + transform: scale(1); +} + +.modal-header { + display: flex; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-header h3 { + font-size: 18px; +} + +.btn-close { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: var(--text-secondary); +} + +.drop-zone { + border: 2px dashed #e2e8f0; + border-radius: 16px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.drop-zone:hover, +.drop-zone.active { + border-color: var(--primary-color); + background: rgba(99, 102, 241, 0.05); +} + +.drop-zone .icon-wrapper { + font-size: 32px; + color: var(--primary-color); + margin-bottom: 12px; +} + +.support-text { + display: block; + font-size: 12px; + color: #94a3b8; + margin-top: 8px; +} + +/* Loading */ +.loading-overlay { + position: fixed; + inset: 0; + background: rgba(255, 255, 255, 0.9); + z-index: 2000; + display: none; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.loader-dots div { + width: 10px; + height: 10px; + background: var(--primary-color); + border-radius: 50%; + display: inline-block; + animation: bounce 1.4s infinite ease-in-out both; + margin: 0 3px; +} + +.loader-dots div:nth-child(1) { + animation-delay: -0.32s; +} + +.loader-dots div:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes bounce { + + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} + +/* Mobile Response */ +@media (max-width: 640px) { + .app-container { + padding: 12px; + } + + .main-header { + padding: 24px 0 20px; + } + + .logo h1 { + font-size: 24px; + } + + .upload-controls { + flex-direction: column; + align-items: stretch; + } + + .btn-block { + width: 100%; + justify-content: center; + } + + .summary-cards { + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .stat-card { + padding: 16px; + flex-direction: column; + text-align: center; + gap: 8px; + } + + .stat-value { + font-size: 16px; + } + + .icon-circle { + width: 36px; + height: 36px; + font-size: 16px; + margin-bottom: 4px; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .search-wrapper { + width: 100%; + } + + .filter-group { + justify-content: space-between; + } + + .p-meta { + flex-direction: row; + align-items: center; + gap: 8px; + } +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..d14721e --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,340 @@ +// DOM Elements +const elements = { + uploadArea: document.getElementById('uploadArea'), + fileInput: document.getElementById('fileInput'), + loadingOverlay: document.getElementById('loadingOverlay'), + loadingText: document.getElementById('loadingText'), + noData: document.getElementById('noData'), + dataDisplay: document.getElementById('dataDisplay'), + uploadModal: document.getElementById('uploadModal'), + fileList: document.getElementById('fileList'), + fileSelector: document.getElementById('fileSelector'), + dailyData: document.getElementById('dailyData'), + searchInput: document.getElementById('searchInput'), + // Stats + totalAmount: document.getElementById('totalAmount'), + totalQuantity: document.getElementById('totalQuantity'), + totalDays: document.getElementById('totalDays'), + totalProducts: document.getElementById('totalProducts') +}; + +// State +let state = { + allData: null, + filteredData: null, + currentFilter: 'all', + searchTerm: '', + currentFile: null, + fileList: [], + currentPage: 1, + itemsPerPage: 50 +}; + +// --- Event Listeners --- + +document.addEventListener('DOMContentLoaded', () => { + loadFileList(); + if (elements.searchInput) { + elements.searchInput.addEventListener('input', (e) => { + state.searchTerm = e.target.value.toLowerCase(); + applyFilters(); + }); + } +}); + +if (elements.fileInput) { + elements.fileInput.addEventListener('change', (e) => handleFile(e.target.files[0])); +} + +if (elements.uploadArea) { + elements.uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + elements.uploadArea.classList.add('active'); + }); + elements.uploadArea.addEventListener('dragleave', () => { + elements.uploadArea.classList.remove('active'); + }); + elements.uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + elements.uploadArea.classList.remove('active'); + if (e.dataTransfer.files.length > 0) { + handleFile(e.dataTransfer.files[0]); + } + }); + elements.uploadArea.addEventListener('click', () => { + elements.fileInput.click(); + }); +} + +// --- Core Functions --- + +function handleFile(file) { + if (!file) return; + if (!file.name.match(/\.(xlsx|xls)$/i)) { + alert('请选择 Excel 文件 (.xlsx 或 .xls)'); + return; + } + closeUploadModal(); + uploadFile(file); +} + +function uploadFile(file) { + const formData = new FormData(); + formData.append('file', file); + showLoading(true, '正在上传并分析...'); + + fetch('/upload', { method: 'POST', body: formData }) + .then(res => res.json()) + .then(data => { + setTimeout(() => { + showLoading(false); + if (data.success) { + state.currentFile = data.filename; + displayData(data.data); + loadFileList(); + } else { + alert(data.error || '上传失败'); + } + }, 500); + }) + .catch(err => { + showLoading(false); + alert('上传错误: ' + err.message); + }); +} + +function loadFileList() { + fetch('/files') + .then(res => res.json()) + .then(data => { + if (data.files && data.files.length > 0) { + state.fileList = data.files; + renderFileList(data.files); + if (!state.currentFile) { + loadFile(data.files[0].filename); + } + } else { + elements.fileSelector.style.display = 'none'; + } + }); +} + +function loadFile(filename) { + showLoading(true, '加载数据中...'); + fetch(`/load/${filename}`) + .then(res => res.json()) + .then(data => { + setTimeout(() => { + showLoading(false); + if (data.success) { + state.currentFile = filename; + displayData(data.data); + renderFileList(state.fileList); // Update active state + } else { + elements.noData.style.display = 'flex'; + elements.dataDisplay.style.display = 'none'; + } + }, 300); + }) + .catch(() => { + showLoading(false); + elements.noData.style.display = 'flex'; + }); +} + +function displayData(data) { + state.allData = data; + elements.noData.style.display = 'none'; + elements.dataDisplay.style.display = 'block'; + + applyFilters(); +} + +function renderStats(data) { + let totalQty = 0; + let totalAmt = 0; + const days = data.daily_summary.length; + const uniqueProducts = new Set(); // Simplified unique product count logic based on daily summary data structure might differ, here we approximate or iterate raw if needed. + // Correction: Use raw_data for unique products count if available, or iterate all dailies. + + data.daily_summary.forEach(day => { + if (day.summary_info) { + totalQty += day.summary_info.total_quantity; + totalAmt += day.summary_info.total_amount; + } else { + totalQty += day.total_quantity; + totalAmt += day.total_amount; + } + day.products.forEach(p => uniqueProducts.add(p.product)); + }); + + // Count Animation + animateValue(elements.totalAmount, totalAmt, '¥'); + animateValue(elements.totalQuantity, totalQty, ''); + animateValue(elements.totalDays, days, ''); + animateValue(elements.totalProducts, uniqueProducts.size, ''); +} + +function animateValue(obj, end, prefix = '') { + if (!obj) return; + let startTimestamp = null; + const duration = 1000; + const start = 0; + + const step = (timestamp) => { + if (!startTimestamp) startTimestamp = timestamp; + const progress = Math.min((timestamp - startTimestamp) / duration, 1); + const value = Math.floor(progress * (end - start) + start); + + if (prefix === '¥') { + obj.innerHTML = prefix + (progress * end).toFixed(2); + } else { + obj.innerHTML = prefix + value; + } + + if (progress < 1) { + window.requestAnimationFrame(step); + } else { + if (prefix === '¥') { + obj.innerHTML = prefix + end.toFixed(2); + } else { + obj.innerHTML = prefix + end; + } + } + }; + window.requestAnimationFrame(step); +} + +function renderDailyList(data) { + elements.dailyData.innerHTML = data.daily_summary.map(day => { + const totalAmt = day.summary_info ? day.summary_info.total_amount : day.total_amount; + const totalQty = day.summary_info ? day.summary_info.total_quantity : day.total_quantity; + + return ` +
+
+
+ ${day.date} + ¥${totalAmt.toFixed(2)} / ${totalQty}件 +
+ +
+
+
+ ${day.products.map(p => ` +
+
${p.product}
+
+ ¥${p.price.toFixed(2)} + x${p.quantity} + ¥${p.amount.toFixed(2)} +
+
+ `).join('')} +
+
+
+ `; + }).join(''); +} + +function toggleDayDetails(header) { + const card = header.parentElement; + card.classList.toggle('expanded'); +} + +function applyFilters() { + if (!state.allData) return; + + let filtered = JSON.parse(JSON.stringify(state.allData)); + + // Search Filter + if (state.searchTerm) { + filtered.daily_summary = filtered.daily_summary.map(day => { + const matchedProducts = day.products.filter(p => p.product.toLowerCase().includes(state.searchTerm)); + if (matchedProducts.length > 0) { + const tQty = matchedProducts.reduce((a, b) => a + b.quantity, 0); + const tAmt = matchedProducts.reduce((a, b) => a + b.amount, 0); + return { ...day, products: matchedProducts, total_quantity: tQty, total_amount: tAmt, summary_info: null }; + } + return null; + }).filter(d => d); + } + + // Amount Filter + if (state.currentFilter !== 'all') { + filtered.daily_summary = filtered.daily_summary.filter(day => { + const amt = day.summary_info ? day.summary_info.total_amount : day.total_amount; + if (state.currentFilter === 'high') return amt >= 1000; + if (state.currentFilter === 'medium') return amt >= 100 && amt < 1000; + if (state.currentFilter === 'low') return amt < 100; + return true; + }); + } + + state.filteredData = filtered; + renderStats(filtered); + renderDailyList(filtered); +} + +function filterByAmount(type) { + state.currentFilter = type; + document.querySelectorAll('.filter-chip').forEach(btn => btn.classList.remove('active')); + event.target.classList.add('active'); + applyFilters(); +} + +function renderFileList(files) { + elements.fileSelector.style.display = 'flex'; + elements.fileList.innerHTML = files.map(f => ` +
+ ${f.original_name} +
+ `).join(''); +} + +// --- Utils --- + +function openUploadModal() { + elements.uploadModal.classList.add('active'); +} + +function closeUploadModal() { + elements.uploadModal.classList.remove('active'); +} + +function showLoading(show, text) { + elements.loadingOverlay.style.display = show ? 'flex' : 'none'; + if (text) elements.loadingText.textContent = text; +} + +function cleanupFiles() { + if (!confirm('确认清理旧文件?')) return; + fetch('/cleanup', { method: 'POST' }) + .then(res => res.json()) + .then(data => { + alert(data.message); + loadFileList(); + if (data.deleted_count > 0 && state.currentFile) { + // Check if current file still exists? Simple approach: reload list + // Logic omitted for brevity, user can re-upload + } + }); +} + +function exportData() { + if (!state.allData) return; + const data = state.filteredData || state.allData; + let csv = "Date,Product,Quantity,Price,Amount\n"; + data.daily_summary.forEach(day => { + day.products.forEach(p => { + csv += `${day.date},${p.product},${p.quantity},${p.price},${p.amount}\n`; + }); + }); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'sales_data.csv'; + a.click(); +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9a44f91 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,163 @@ + + + + + + + 销售数据分析器 + + + + + + +
+
+ +

智能销售分析助手

+
+ +
+ +
+
+

开始分析

+
+ +
+ + + + + +
+
+ + +
+
+ +
+

暂无数据

+

上传 Excel 文件即可生成精美报表

+
+ + + +
+
+ + + + + +
+
+
+
+
+
+
+

处理中...

+
+
+ + + + + + \ No newline at end of file