diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..36c9fd3 Binary files /dev/null and b/backend/__pycache__/app.cpython-313.pyc differ diff --git a/backend/api/__pycache__/__init__.cpython-313.pyc b/backend/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..faa91f6 Binary files /dev/null and b/backend/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/api/__pycache__/data_import.cpython-313.pyc b/backend/api/__pycache__/data_import.cpython-313.pyc new file mode 100644 index 0000000..2db8c6a Binary files /dev/null and b/backend/api/__pycache__/data_import.cpython-313.pyc differ diff --git a/backend/api/__pycache__/projects.cpython-313.pyc b/backend/api/__pycache__/projects.cpython-313.pyc new file mode 100644 index 0000000..c6a691e Binary files /dev/null and b/backend/api/__pycache__/projects.cpython-313.pyc differ diff --git a/backend/api/__pycache__/statistics.cpython-313.pyc b/backend/api/__pycache__/statistics.cpython-313.pyc new file mode 100644 index 0000000..564ff97 Binary files /dev/null and b/backend/api/__pycache__/statistics.cpython-313.pyc differ diff --git a/backend/api/__pycache__/timerecords.cpython-313.pyc b/backend/api/__pycache__/timerecords.cpython-313.pyc new file mode 100644 index 0000000..b7660d6 Binary files /dev/null and b/backend/api/__pycache__/timerecords.cpython-313.pyc differ diff --git a/backend/api/data_import.py b/backend/api/data_import.py new file mode 100644 index 0000000..8a8fe16 --- /dev/null +++ b/backend/api/data_import.py @@ -0,0 +1,118 @@ +from flask import Blueprint, request, jsonify +from models.models import db, Project, TimeRecord, Holiday, ImportBatch +from models.utils import calculate_hours, is_holiday, get_week_info +from datetime import datetime +import re +import json + +data_import_bp = Blueprint('data_import', __name__) + +@data_import_bp.route('/import', methods=['POST']) +def import_records(): + """批量导入工时记录并记录导入历史""" + data = request.json + records_text = data.get('records', '') + lines = records_text.strip().split('\n') + total_records = len([line for line in lines if line.strip()]) + + success_count = 0 + failures = [] + + projects = {p.project_name: p for p in Project.query.all()} + holidays = Holiday.query.all() + current_year = datetime.now().year + + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + + match = re.match(r'^(\d{1,2})月(\d{1,2})日\s+(.+?)\s+(\d{1,2}:\d{2})\s+(\d{1,2}:\d{2})\s+(.*)$', line) + + if not match: + failures.append({'line': line, 'reason': '格式不匹配'}) + continue + + try: + month, day, project_name, start_time_str, end_time_str, activity_num = match.groups() + project_name = project_name.strip() + activity_num = activity_num.strip() + + record_date = datetime(current_year, int(month), int(day)).date() + + if project_name not in projects: + failures.append({'line': line, 'reason': f'项目 "{project_name}" 不存在'}) + continue + + project = projects[project_name] + start_time = datetime.strptime(start_time_str, '%H:%M').time() + end_time = datetime.strptime(end_time_str, '%H:%M').time() + + holiday_info = is_holiday(record_date, holidays) + hours = calculate_hours(start_time_str, end_time_str, holiday_info['is_holiday']) + week_info = get_week_info(record_date) + + record = TimeRecord( + date=record_date, + event_description=f"批量导入 - {project_name}", + project_id=project.id, + start_time=start_time, + end_time=end_time, + activity_num=activity_num, + hours=hours, + is_holiday=holiday_info['is_holiday'], + is_working_on_holiday=holiday_info['is_holiday'] and hours not in ['-', '0:00'], + holiday_type=holiday_info['holiday_type'], + week_info=week_info + ) + db.session.add(record) + success_count += 1 + + except Exception as e: + failures.append({'line': line, 'reason': str(e)}) + + # 决定导入状态 + status = "失败" + if success_count == total_records and total_records > 0: + status = "成功" + elif success_count > 0: + status = "部分成功" + + # 创建并保存导入批次记录 + batch = ImportBatch( + status=status, + success_count=success_count, + failure_count=len(failures), + total_records=total_records, + source_preview='\n'.join(lines[:5]), # 保存前5行作为预览 + failures_log=json.dumps(failures, ensure_ascii=False) if failures else None + ) + db.session.add(batch) + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({ + 'success': False, + 'error': f'数据库提交失败: {str(e)}', + 'success_count': 0, + 'failure_count': total_records, + 'failures': [{'line': l, 'reason': '数据库错误'} for l in lines] + }), 500 + + return jsonify({ + 'success': success_count > 0, + 'success_count': success_count, + 'failure_count': len(failures), + 'failures': failures + }) + +@data_import_bp.route('/import/history', methods=['GET']) +def get_import_history(): + """获取导入历史记录""" + try: + history = ImportBatch.query.order_by(ImportBatch.import_date.desc()).all() + return jsonify([h.to_dict() for h in history]) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 \ No newline at end of file diff --git a/backend/api/projects.py b/backend/api/projects.py index 09d878a..8007d60 100644 --- a/backend/api/projects.py +++ b/backend/api/projects.py @@ -1,25 +1,17 @@ from flask import Blueprint, request, jsonify -from sqlalchemy.orm import sessionmaker -from sqlalchemy import create_engine -from backend.models.models import Project, ProjectType -from backend.models.utils import * +from models.models import db, Project, ProjectType +from models.utils import * import csv import io +from datetime import datetime projects_bp = Blueprint('projects', __name__) -def get_db_session(): - """获取数据库会话""" - engine = create_engine('sqlite:///data/timetrack.db') - Session = sessionmaker(bind=engine) - return Session() - @projects_bp.route('/api/projects', methods=['GET']) def get_projects(): """获取所有项目列表""" try: - session = get_db_session() - projects = session.query(Project).filter_by(is_active=True).all() + projects = db.session.query(Project).filter_by(is_active=True).all() result = [] for project in projects: @@ -32,10 +24,10 @@ def get_projects(): result.append(project_dict) - session.close() return jsonify({'success': True, 'data': result}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @projects_bp.route('/api/projects', methods=['POST']) @@ -43,14 +35,19 @@ def create_project(): """创建新项目""" try: data = request.json - session = get_db_session() # 验证必填字段 if not data.get('project_name') or not data.get('customer_name') or not data.get('project_type'): return jsonify({'success': False, 'error': '项目名称、客户名和项目类型为必填项'}), 400 + project_type_str = data['project_type'] + try: + project_type = ProjectType(project_type_str) + except ValueError: + return jsonify({'success': False, 'error': f'无效的项目类型: {project_type_str}'}), 400 + # 根据项目类型设置字段 - if data['project_type'] == 'traditional': + if project_type == ProjectType.TRADITIONAL: if not data.get('project_code'): return jsonify({'success': False, 'error': '传统项目需要填写项目代码'}), 400 project_code = data['project_code'] @@ -60,47 +57,64 @@ def create_project(): return jsonify({'success': False, 'error': 'PSI项目需要填写合同号'}), 400 project_code = 'PSI-PROJ' # PSI项目统一代码 contract_number = data['contract_number'] + + # 处理结束日期 + end_date = None + if data.get('end_date') and data.get('end_date') != '' : + try: + end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'success': False, 'error': '结束日期格式不正确,应为 YYYY-MM-DD'}), 400 + + # 处理开始日期 + start_date = None + if data.get('start_date') and data.get('start_date') != '' : + try: + start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'success': False, 'error': '开始日期格式不正确,应为 YYYY-MM-DD'}), 400 # 检查唯一性约束 existing_project = None - if data['project_type'] == 'traditional': + if project_type == ProjectType.TRADITIONAL: # 传统项目:客户名+项目代码唯一 - existing_project = session.query(Project).filter_by( + existing_project = db.session.query(Project).filter_by( customer_name=data['customer_name'], project_code=project_code, project_type=ProjectType.TRADITIONAL ).first() else: # PSI项目:客户名+合同号唯一 - existing_project = session.query(Project).filter_by( + existing_project = db.session.query(Project).filter_by( customer_name=data['customer_name'], contract_number=contract_number, project_type=ProjectType.PSI ).first() if existing_project: - session.close() return jsonify({'success': False, 'error': '项目已存在'}), 400 # 创建新项目 project = Project( project_name=data['project_name'], - project_type=ProjectType(data['project_type']), + project_type=project_type, project_code=project_code, customer_name=data['customer_name'], contract_number=contract_number, - description=data.get('description', '') + description=data.get('description', ''), + start_date=start_date, + end_date=end_date ) - session.add(project) - session.commit() + db.session.add(project) + db.session.commit() result = project.to_dict() - session.close() return jsonify({'success': True, 'data': result}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @projects_bp.route('/api/projects/', methods=['PUT']) @@ -108,11 +122,9 @@ def update_project(project_id): """更新项目信息""" try: data = request.json - session = get_db_session() - project = session.query(Project).get(project_id) + project = db.session.query(Project).get(project_id) if not project: - session.close() return jsonify({'success': False, 'error': '项目不存在'}), 404 # 更新字段 @@ -121,33 +133,30 @@ def update_project(project_id): if 'description' in data: project.description = data['description'] - session.commit() + db.session.commit() result = project.to_dict() - session.close() return jsonify({'success': True, 'data': result}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @projects_bp.route('/api/projects/', methods=['DELETE']) def delete_project(project_id): """删除项目(软删除)""" try: - session = get_db_session() - - project = session.query(Project).get(project_id) + project = db.session.query(Project).get(project_id) if not project: - session.close() return jsonify({'success': False, 'error': '项目不存在'}), 404 project.is_active = False - session.commit() - session.close() + db.session.commit() return jsonify({'success': True, 'message': '项目已删除'}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @projects_bp.route('/api/projects/import', methods=['POST']) @@ -161,8 +170,6 @@ def import_projects(): if file.filename == '' or not file.filename.endswith('.csv'): return jsonify({'success': False, 'error': '请选择有效的CSV文件'}), 400 - session = get_db_session() - # 读取CSV文件 stream = io.StringIO(file.stream.read().decode("utf-8")) csv_reader = csv.DictReader(stream) @@ -177,13 +184,15 @@ def import_projects(): errors.append(f"第{row_num}行:项目名称、客户名和项目类型为必填项") continue - project_type = row['项目类型'].lower() - if project_type not in ['traditional', 'psi']: + project_type_str = row['项目类型'].lower() + try: + project_type = ProjectType(project_type_str) + except ValueError: errors.append(f"第{row_num}行:项目类型只能是 traditional 或 psi") continue # 根据项目类型设置字段 - if project_type == 'traditional': + if project_type == ProjectType.TRADITIONAL: if not row.get('项目代码'): errors.append(f"第{row_num}行:传统项目需要填写项目代码") continue @@ -198,14 +207,14 @@ def import_projects(): # 检查重复 existing_project = None - if project_type == 'traditional': - existing_project = session.query(Project).filter_by( + if project_type == ProjectType.TRADITIONAL: + existing_project = db.session.query(Project).filter_by( customer_name=row['客户名'], project_code=project_code, project_type=ProjectType.TRADITIONAL ).first() else: - existing_project = session.query(Project).filter_by( + existing_project = db.session.query(Project).filter_by( customer_name=row['客户名'], contract_number=contract_number, project_type=ProjectType.PSI @@ -218,21 +227,20 @@ def import_projects(): # 创建项目 project = Project( project_name=row['项目名称'], - project_type=ProjectType(project_type), + project_type=project_type, project_code=project_code, customer_name=row['客户名'], contract_number=contract_number, description=row.get('描述', '') ) - session.add(project) + db.session.add(project) created_count += 1 except Exception as e: errors.append(f"第{row_num}行:{str(e)}") - session.commit() - session.close() + db.session.commit() return jsonify({ 'success': True, @@ -242,4 +250,5 @@ def import_projects(): }) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 \ No newline at end of file diff --git a/backend/api/statistics.py b/backend/api/statistics.py index fda461e..29a6575 100644 --- a/backend/api/statistics.py +++ b/backend/api/statistics.py @@ -1,35 +1,25 @@ from flask import Blueprint, request, jsonify -from sqlalchemy.orm import sessionmaker -from sqlalchemy import create_engine, and_ -from backend.models.models import TimeRecord, Project, CutoffPeriod -from backend.models.utils import * +from sqlalchemy import and_ +from models.models import db, TimeRecord, Project, CutoffPeriod, Holiday +from models.utils import * from datetime import datetime, date, timedelta from collections import defaultdict statistics_bp = Blueprint('statistics', __name__) -def get_db_session(): - """获取数据库会话""" - engine = create_engine('sqlite:///data/timetrack.db') - Session = sessionmaker(bind=engine) - return Session() - @statistics_bp.route('/api/statistics/weekly', methods=['GET']) def get_weekly_statistics(): """获取周统计数据""" try: - session = get_db_session() - # 获取查询参数 period_id = request.args.get('period_id') - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') # 如果指定了周期ID,使用周期的日期范围 if period_id: - period = session.query(CutoffPeriod).get(int(period_id)) + period = db.session.query(CutoffPeriod).get(int(period_id)) if not period: - session.close() return jsonify({'success': False, 'error': '周期不存在'}), 404 start_date = period.start_date @@ -38,12 +28,11 @@ def get_weekly_statistics(): period_info = period.to_dict() else: # 使用指定的日期范围 - if not start_date or not end_date: - session.close() + if not start_date_str or not end_date_str: return jsonify({'success': False, 'error': '请提供开始日期和结束日期'}), 400 - start_date = datetime.strptime(start_date, '%Y-%m-%d').date() - end_date = datetime.strptime(end_date, '%Y-%m-%d').date() + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() target_hours = 40 # 默认目标工时 period_info = { 'period_name': f"{start_date.strftime('%m月%d日')}-{end_date.strftime('%m月%d日')}", @@ -53,7 +42,7 @@ def get_weekly_statistics(): } # 查询该时间范围内的所有工时记录 - records = session.query(TimeRecord).filter( + records = db.session.query(TimeRecord).filter( and_(TimeRecord.date >= start_date, TimeRecord.date <= end_date) ).order_by(TimeRecord.date).all() @@ -68,6 +57,9 @@ def get_weekly_statistics(): record_dict[record.date] = [] record_dict[record.date].append(record) + # 获取时间范围内的所有假期定义,避免在循环中重复查询 + holidays_in_range = db.session.query(Holiday).all() + # 生成每日汇总 while current_date <= end_date: day_records = record_dict.get(current_date, []) @@ -104,8 +96,7 @@ def get_weekly_statistics(): } else: # 如果没有记录,生成默认记录 - holidays = session.query(Holiday).all() - holiday_info = is_holiday(current_date, holidays) + holiday_info = is_holiday(current_date, holidays_in_range) daily_record = { 'date': current_date.isoformat(), @@ -183,7 +174,6 @@ def get_weekly_statistics(): 'completion_rate': round((workday_total + holiday_total) / target_hours * 100, 1) if target_hours > 0 else 0 } - session.close() return jsonify({'success': True, 'data': result}) except Exception as e: @@ -193,14 +183,9 @@ def get_weekly_statistics(): def get_cutoff_periods(): """获取Cut-Off周期列表""" try: - session = get_db_session() - - periods = session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all() + periods = db.session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all() result = [period.to_dict() for period in periods] - - session.close() return jsonify({'success': True, 'data': result}) - except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -209,7 +194,6 @@ def create_cutoff_period(): """创建Cut-Off周期""" try: data = request.json - session = get_db_session() # 验证必填字段 if not all(key in data for key in ['period_name', 'start_date', 'end_date']): @@ -235,33 +219,29 @@ def create_cutoff_period(): month=start_date.month ) - session.add(period) - session.commit() + db.session.add(period) + db.session.commit() result = period.to_dict() - session.close() - return jsonify({'success': True, 'data': result}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @statistics_bp.route('/api/statistics/periods/', methods=['DELETE']) def delete_cutoff_period(period_id): """删除Cut-Off周期""" try: - session = get_db_session() - - period = session.query(CutoffPeriod).get(period_id) + period = db.session.query(CutoffPeriod).get(period_id) if not period: - session.close() return jsonify({'success': False, 'error': '周期不存在'}), 404 - session.delete(period) - session.commit() - session.close() + db.session.delete(period) + db.session.commit() return jsonify({'success': True, 'message': '周期已删除'}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 \ No newline at end of file diff --git a/backend/api/timerecords.py b/backend/api/timerecords.py index be2b4ab..85dd08f 100644 --- a/backend/api/timerecords.py +++ b/backend/api/timerecords.py @@ -1,31 +1,22 @@ from flask import Blueprint, request, jsonify -from sqlalchemy.orm import sessionmaker -from sqlalchemy import create_engine, and_ -from backend.models.models import TimeRecord, Project, Holiday -from backend.models.utils import * +from sqlalchemy import and_ +from models.models import db, TimeRecord, Project, Holiday +from models.utils import * from datetime import datetime, date import json timerecords_bp = Blueprint('timerecords', __name__) -def get_db_session(): - """获取数据库会话""" - engine = create_engine('sqlite:///data/timetrack.db') - Session = sessionmaker(bind=engine) - return Session() - @timerecords_bp.route('/api/timerecords', methods=['GET']) def get_timerecords(): """获取工时记录列表""" try: - session = get_db_session() - # 获取查询参数 start_date = request.args.get('start_date') end_date = request.args.get('end_date') project_id = request.args.get('project_id') - query = session.query(TimeRecord) + query = db.session.query(TimeRecord).options(db.joinedload(TimeRecord.project)) # 应用筛选条件 if start_date: @@ -45,7 +36,6 @@ def get_timerecords(): record_dict['day_of_week'] = get_day_of_week_chinese(record.date) result.append(record_dict) - session.close() return jsonify({'success': True, 'data': result}) except Exception as e: @@ -56,7 +46,6 @@ def create_timerecord(): """创建工时记录""" try: data = request.json - session = get_db_session() # 验证必填字段 if not data.get('date'): @@ -65,7 +54,7 @@ def create_timerecord(): record_date = datetime.strptime(data['date'], '%Y-%m-%d').date() # 检查是否为休息日 - holidays = session.query(Holiday).all() + holidays = db.session.query(Holiday).all() holiday_info = is_holiday(record_date, holidays) # 计算工时 @@ -93,18 +82,17 @@ def create_timerecord(): week_info=week_info ) - session.add(record) - session.commit() + db.session.add(record) + db.session.commit() result = record.to_dict() if record.date: result['day_of_week'] = get_day_of_week_chinese(record.date) - session.close() - return jsonify({'success': True, 'data': result}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @timerecords_bp.route('/api/timerecords/', methods=['PUT']) @@ -112,11 +100,9 @@ def update_timerecord(record_id): """更新工时记录""" try: data = request.json - session = get_db_session() - record = session.query(TimeRecord).get(record_id) + record = db.session.query(TimeRecord).get(record_id) if not record: - session.close() return jsonify({'success': False, 'error': '记录不存在'}), 404 # 更新字段 @@ -144,47 +130,41 @@ def update_timerecord(record_id): # 更新工作日状态 record.is_working_on_holiday = record.is_holiday and record.hours not in ['-', '0:00'] - session.commit() + db.session.commit() result = record.to_dict() if record.date: result['day_of_week'] = get_day_of_week_chinese(record.date) - session.close() - return jsonify({'success': True, 'data': result}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @timerecords_bp.route('/api/timerecords/', methods=['DELETE']) def delete_timerecord(record_id): """删除工时记录""" try: - session = get_db_session() - - record = session.query(TimeRecord).get(record_id) + record = db.session.query(TimeRecord).get(record_id) if not record: - session.close() return jsonify({'success': False, 'error': '记录不存在'}), 404 - session.delete(record) - session.commit() - session.close() + db.session.delete(record) + db.session.commit() return jsonify({'success': True, 'message': '记录已删除'}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'error': str(e)}), 500 @timerecords_bp.route('/api/timerecords/check_holiday/', methods=['GET']) def check_holiday(date_str): """检查指定日期是否为休息日""" try: - session = get_db_session() - check_date = datetime.strptime(date_str, '%Y-%m-%d').date() - holidays = session.query(Holiday).all() + holidays = db.session.query(Holiday).all() holiday_info = is_holiday(check_date, holidays) result = { @@ -196,7 +176,6 @@ def check_holiday(date_str): 'week_info': get_week_info(check_date) } - session.close() return jsonify({'success': True, 'data': result}) except Exception as e: diff --git a/backend/app.py b/backend/app.py index ce4cf3f..045c2a0 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,10 +1,10 @@ from flask import Flask, render_template from flask_cors import CORS -from sqlalchemy import create_engine -from backend.models.models import Base -from backend.api.projects import projects_bp -from backend.api.timerecords import timerecords_bp -from backend.api.statistics import statistics_bp +from models.models import db +from api.projects import projects_bp +from api.timerecords import timerecords_bp +from api.statistics import statistics_bp +from api.data_import import data_import_bp import os def create_app(): @@ -15,17 +15,27 @@ def create_app(): # 启用CORS支持 CORS(app) - # 确保数据目录存在 - os.makedirs('data', exist_ok=True) + # 数据库配置 + # 获取项目根目录下的data文件夹路径 + data_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data') + os.makedirs(data_dir, exist_ok=True) + db_path = os.path.join(data_dir, 'timetrack.db') - # 创建数据库表 - engine = create_engine('sqlite:///data/timetrack.db') - Base.metadata.create_all(engine) + app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + # 初始化数据库 + db.init_app(app) + + # 在应用上下文中创建数据库表 + with app.app_context(): + db.create_all() # 注册蓝图 app.register_blueprint(projects_bp) app.register_blueprint(timerecords_bp) app.register_blueprint(statistics_bp) + app.register_blueprint(data_import_bp) # 主页路由 @app.route('/') @@ -44,6 +54,10 @@ def create_app(): def statistics(): return render_template('statistics.html') + @app.route('/import') + def import_page(): + return render_template('import.html') + return app if __name__ == '__main__': diff --git a/backend/models/__pycache__/__init__.cpython-313.pyc b/backend/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d5febe3 Binary files /dev/null and b/backend/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/models/__pycache__/models.cpython-313.pyc b/backend/models/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..a982bcc Binary files /dev/null and b/backend/models/__pycache__/models.cpython-313.pyc differ diff --git a/backend/models/__pycache__/utils.cpython-313.pyc b/backend/models/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..d009a91 Binary files /dev/null and b/backend/models/__pycache__/utils.cpython-313.pyc differ diff --git a/backend/models/models.py b/backend/models/models.py index 741a6a5..a9cb71a 100644 --- a/backend/models/models.py +++ b/backend/models/models.py @@ -1,16 +1,16 @@ +from flask_sqlalchemy import SQLAlchemy from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Date, Time, ForeignKey, Enum -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from datetime import datetime import enum -Base = declarative_base() +db = SQLAlchemy() class ProjectType(enum.Enum): TRADITIONAL = "traditional" # 传统项目 PSI = "psi" # PSI项目 -class Project(Base): +class Project(db.Model): """项目表模型""" __tablename__ = 'projects' @@ -26,6 +26,8 @@ class Project(Base): contract_number = Column(String(100)) # 合同号,PSI项目必填 description = Column(Text) + start_date = Column(Date, nullable=True) # 项目开始时间 + end_date = Column(Date, nullable=True) # 项目结束时间 is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -42,12 +44,14 @@ class Project(Base): 'customer_name': self.customer_name, 'contract_number': self.contract_number, 'description': self.description, + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, 'is_active': self.is_active, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None } -class TimeRecord(Base): +class TimeRecord(db.Model): """工时记录表模型""" __tablename__ = 'time_records' @@ -88,7 +92,7 @@ class TimeRecord(Base): 'updated_at': self.updated_at.isoformat() if self.updated_at else None } -class Holiday(Base): +class Holiday(db.Model): """休息日配置表模型""" __tablename__ = 'holidays' @@ -109,7 +113,7 @@ class Holiday(Base): 'created_at': self.created_at.isoformat() if self.created_at else None } -class CutoffPeriod(Base): +class CutoffPeriod(db.Model): """Cut-Off周期表模型""" __tablename__ = 'cutoff_periods' @@ -134,4 +138,29 @@ class CutoffPeriod(Base): 'year': self.year, 'month': self.month, 'created_at': self.created_at.isoformat() if self.created_at else None - } \ No newline at end of file + } + +class ImportBatch(db.Model): + """导入批次历史记录模型""" + __tablename__ = 'import_batches' + + id = Column(Integer, primary_key=True) + import_date = Column(DateTime, default=datetime.utcnow) + status = Column(String(50), nullable=False) + success_count = Column(Integer, default=0) + failure_count = Column(Integer, default=0) + total_records = Column(Integer, default=0) + source_preview = Column(Text) + failures_log = Column(Text) # 存储失败记录的详细日志 + + def to_dict(self): + return { + 'id': self.id, + 'import_date': self.import_date.isoformat(), + 'status': self.status, + 'success_count': self.success_count, + 'failure_count': self.failure_count, + 'total_records': self.total_records, + 'source_preview': self.source_preview, + 'failures_log': self.failures_log + } diff --git a/backend/models/utils.py b/backend/models/utils.py index 103235b..e9c8d46 100644 --- a/backend/models/utils.py +++ b/backend/models/utils.py @@ -1,12 +1,11 @@ import datetime from typing import Optional, Dict, Any, List -from backend.models.models import Holiday def is_weekend(date: datetime.date) -> bool: """判断是否为周末""" return date.weekday() >= 5 # 周六=5, 周日=6 -def is_holiday(date: datetime.date, holidays: List[Holiday] = None) -> Dict[str, Any]: +def is_holiday(date: datetime.date, holidays: list = None) -> Dict[str, Any]: """检测指定日期是否为休息日""" day_of_week = date.weekday() # 0=周一, 6=周日 is_weekend_day = day_of_week >= 5 # 周六、周日 diff --git a/data/timetrack.db b/data/timetrack.db new file mode 100644 index 0000000..7c8bb79 Binary files /dev/null and b/data/timetrack.db differ diff --git a/requirements.txt b/requirements.txt index 65ab200..fb19eaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ Flask==2.3.3 Flask-CORS==4.0.0 -SQLAlchemy==2.0.23 -Flask-SQLAlchemy==3.1.1 -python-dateutil==2.8.2 -openpyxl==3.1.2 -pandas==2.1.4 \ No newline at end of file +SQLAlchemy==1.4.54 +Flask-SQLAlchemy==3.0.5 +python-dateutil==2.8.2 \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..f8900c4 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,580 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* CSS Variables for easy theming */ +:root { + --primary-color: #4a90e2; + --primary-hover-color: #357abd; + --secondary-color: #f5f7fa; + --text-color: #333; + --text-light-color: #666; + --border-color: #e0e0e0; + --background-color: #f8f9fa; + --white-color: #fff; + --danger-color: #e74c3c; + --danger-hover-color: #c0392b; + --success-color: #2ecc71; + --warning-color: #f39c12; + --font-family: 'Inter', sans-serif; + --border-radius: 8px; + --box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +/* Global Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + line-height: 1.6; + color: var(--text-color); + background-color: var(--background-color); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +/* Navigation Bar */ +.navbar { + background-color: var(--white-color); + padding: 1rem 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-brand h1 { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary-color); +} + +.nav-menu { + display: flex; + list-style: none; + gap: 1rem; +} + +.nav-link { + color: var(--text-light-color); + text-decoration: none; + padding: 0.5rem 1rem; + border-radius: var(--border-radius); + transition: background-color 0.3s, color 0.3s; + font-weight: 500; +} + +.nav-link:hover { + background-color: var(--secondary-color); + color: var(--primary-color); +} + +.nav-link.active { + background-color: var(--primary-color); + color: var(--white-color); + font-weight: 600; +} + +/* Main Content Area */ +.main-content { + padding-top: 2rem; +} + +/* Page Header */ +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.page-header h2 { + color: var(--text-color); + font-size: 2rem; + font-weight: 700; +} + +/* Buttons */ +.btn { + padding: 12px 20px; + border: none; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 14px; + text-decoration: none; + display: inline-block; + text-align: center; + transition: all 0.3s; + font-weight: 600; +} + +.btn-primary { + background-color: var(--primary-color); + color: var(--white-color); +} + +.btn-primary:hover { + background-color: var(--primary-hover-color); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(74, 144, 226, 0.3); +} + +.btn-secondary { + background-color: var(--secondary-color); + color: var(--text-color); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background-color: #e9ecef; +} + +.btn-outline { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); +} + +.btn-outline:hover { + background-color: var(--primary-color); + color: var(--white-color); +} + +.btn-danger { + background-color: var(--danger-color); + color: var(--white-color); +} + +.btn-danger:hover { + background-color: var(--danger-hover-color); +} + +/* Forms */ +.form-group { + margin-bottom: 1.5rem; +} + +.form-row { + display: flex; + gap: 1.5rem; +} + +.form-row .form-group { + flex: 1; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-color); +} + +.form-control { + width: 100%; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: 14px; + transition: border-color 0.3s, box-shadow 0.3s; + background-color: var(--white-color); +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2); +} + +/* Tables */ +.table-container { + background: var(--white-color); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + overflow: hidden; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th { + background-color: var(--secondary-color); + color: var(--text-color); + padding: 1rem; + text-align: left; + font-weight: 600; + font-size: 14px; + border-bottom: 2px solid var(--border-color); +} + +.data-table td { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + font-size: 14px; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:hover { + background-color: #f1f3f5; +} + +/* Modals */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(5px); +} + +.modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background: var(--white-color); + border-radius: var(--border-radius); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + animation: slide-down 0.3s ease-out; +} + +@keyframes slide-down { + from { + transform: translateY(-30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + margin: 0; + color: var(--text-color); + font-weight: 600; +} + +.close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--text-light-color); + transition: color 0.3s; +} + +.close-btn:hover { + color: var(--text-color); +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + padding: 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 1rem; + background-color: var(--secondary-color); +} + +/* Card Styles */ +.card { + background: var(--white-color); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + margin-bottom: 2rem; + overflow: hidden; +} + +.card-header { + padding: 1.5rem; + background-color: var(--secondary-color); + border-bottom: 1px solid var(--border-color); +} + +.card-header h3 { + color: var(--text-color); + margin: 0; + font-weight: 600; +} + +.card-body { + padding: 1.5rem; +} + +/* Filter and Control Sections */ +.filter-section, .period-control { + background: var(--white-color); + padding: 1.5rem; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + margin-bottom: 2rem; +} + +.filter-row, .date-range { + display: flex; + gap: 1rem; + align-items: flex-end; +} + +/* Statistics Specific Styles */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--white-color); + padding: 1.5rem; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + text-align: center; + transition: transform 0.3s, box-shadow 0.3s; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); +} + +.stat-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.stat-label { + color: var(--text-light-color); + font-size: 14px; + font-weight: 500; +} + +/* Welcome Section on Homepage */ +.welcome-section { + text-align: center; + margin-bottom: 3rem; + padding: 4rem 2rem; + background: linear-gradient(135deg, #4a90e2 0%, #5469d4 100%); + color: var(--white-color); + border-radius: var(--border-radius); +} + +.welcome-section h2 { + margin-bottom: 1rem; + font-size: 2.5rem; + font-weight: 700; +} + +.welcome-section p { + font-size: 1.1rem; + opacity: 0.9; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .container { + padding: 1rem; + } + + .nav-container { + flex-direction: column; + gap: 1rem; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .form-row, .filter-row { + flex-direction: column; + gap: 0; + } +} + +/* 统计页面 - 项目分布布局 */ +.distribution-layout { + display: flex; + gap: 2rem; + align-items: flex-start; +} + +.chart-container { + flex: 1 1 50%; + max-width: 50%; + background: var(--white-color); + padding: 1.5rem; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); +} + +.distribution-table { + flex: 1 1 50%; + max-width: 50%; +} + +/* 导入页面特定样式 */ +.import-history-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.import-card { + background: var(--white-color); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + display: flex; + flex-direction: column; +} + +.import-card-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.import-card-header h4 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.import-date { + font-size: 0.9rem; + color: var(--text-light-color); +} + +.import-card-body { + padding: 1.5rem; + flex-grow: 1; +} + +.import-card-stats { + display: flex; + justify-content: space-around; + text-align: center; + margin-bottom: 1.5rem; +} + +.stat-item span { + display: block; +} + +.stat-item .stat-value { + font-size: 1.5rem; + margin-bottom: 0.25rem; +} + +.stat-item .stat-label { + font-size: 0.8rem; +} + +.source-preview { + background-color: var(--secondary-color); + border-radius: var(--border-radius); + padding: 1rem; + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; + font-size: 0.85rem; + max-height: 150px; + overflow-y: auto; +} + +.import-card-footer { + padding: 1rem 1.5rem; + background-color: var(--secondary-color); + border-top: 1px solid var(--border-color); + text-align: right; +} + +.status-badge { + padding: 0.3rem 0.8rem; + border-radius: 12px; + font-weight: 600; + font-size: 0.8rem; + color: var(--white-color); +} + +.status-success { + background-color: var(--success-color); +} + +.status-partial { + background-color: var(--warning-color); +} + +.status-fail { + background-color: var(--danger-color); +} + +.failures-list { + list-style-type: none; + padding-left: 0; + background-color: #fff0f0; + border: 1px solid #ffd0d0; + border-radius: var(--border-radius); + padding: 1rem; + max-height: 200px; + overflow-y: auto; +} + +.failures-list li { + padding: 0.5rem; + border-bottom: 1px solid #ffe0e0; +} + +.failures-list li:last-child { + border-bottom: none; +} diff --git a/static/js/common.js b/static/js/common.js new file mode 100644 index 0000000..839512c --- /dev/null +++ b/static/js/common.js @@ -0,0 +1,373 @@ +// 公共工具函数和API调用 + +// API 基础 URL +const API_BASE_URL = ''; + +// 通用 API 调用函数 +async function apiCall(url, options = {}) { + try { + const response = await fetch(`${API_BASE_URL}${url}`, { + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + ...options + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || `HTTP error! status: ${response.status}`); + } + + return data; + } catch (error) { + console.error('API调用失败:', error); + throw error; + } +} + +// GET 请求 +async function apiGet(url) { + return apiCall(url, { method: 'GET' }); +} + +// POST 请求 +async function apiPost(url, data) { + return apiCall(url, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +// PUT 请求 +async function apiPut(url, data) { + return apiCall(url, { + method: 'PUT', + body: JSON.stringify(data) + }); +} + +// DELETE 请求 +async function apiDelete(url) { + return apiCall(url, { method: 'DELETE' }); +} + +// 显示成功消息 +function showSuccess(message) { + showNotification(message, 'success'); +} + +// 显示错误消息 +function showError(message) { + showNotification(message, 'error'); +} + +// 显示通知 +function showNotification(message, type = 'info') { + // 创建通知元素 + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.innerHTML = ` + ${message} + + `; + + // 添加到页面 + document.body.appendChild(notification); + + // 自动隐藏 + setTimeout(() => { + if (notification.parentElement) { + notification.remove(); + } + }, 5000); +} + +// 格式化日期 +function formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN'); +} + +// 格式化日期时间 +function formatDateTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleString('zh-CN'); +} + +// 获取今天的日期字符串 +function getTodayString() { + const today = new Date(); + return today.toISOString().split('T')[0]; +} + +// 获取本周的开始和结束日期 +function getThisWeekRange() { + const today = new Date(); + const dayOfWeek = today.getDay(); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - dayOfWeek + 1); // 周一 + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); // 周日 + + return { + start: startOfWeek.toISOString().split('T')[0], + end: endOfWeek.toISOString().split('T')[0] + }; +} + +// 模态框控制 +function showModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.add('show'); + modal.style.display = 'flex'; + } +} + +function closeModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.remove('show'); + modal.style.display = 'none'; + } +} + +// 表单重置 +function resetForm(formId) { + const form = document.getElementById(formId); + if (form) { + form.reset(); + } +} + +// 获取表单数据 +function getFormData(formId) { + const form = document.getElementById(formId); + if (!form) return {}; + + const formData = new FormData(form); + const data = {}; + + for (let [key, value] of formData.entries()) { + data[key] = value; + } + + return data; +} + +// 填充表单数据 +function fillForm(formId, data) { + const form = document.getElementById(formId); + if (!form) return; + + Object.keys(data).forEach(key => { + const field = form.querySelector(`[name="${key}"]`); + if (field) { + field.value = data[key] || ''; + } + }); +} + +// 工时格式化函数 +function formatHours(hours) { + if (!hours || hours === '-' || hours === '0:00') { + return hours || '-'; + } + + // 如果包含冒号,直接返回 + if (hours.includes(':')) { + return hours; + } + + // 如果是小数格式,转换为时:分格式 + const decimal = parseFloat(hours); + if (!isNaN(decimal)) { + const h = Math.floor(decimal); + const m = Math.round((decimal - h) * 60); + return `${h}:${m.toString().padStart(2, '0')}`; + } + + return hours; +} + +// 工时转换为小数 +function hoursToDecimal(hours) { + if (!hours || hours === '-') return 0; + + if (hours.includes(':')) { + const [h, m] = hours.split(':').map(Number); + return h + (m || 0) / 60; + } + + return parseFloat(hours) || 0; +} + +// 小数转换为工时格式 +function decimalToHours(decimal) { + if (decimal === 0) return '0:00'; + + const hours = Math.floor(decimal); + const minutes = Math.round((decimal - hours) * 60); + return `${hours}:${minutes.toString().padStart(2, '0')}`; +} + +// 计算工时 +function calculateHours(startTime, endTime) { + if (!startTime || !endTime || startTime === '-' || endTime === '-') { + return '-'; + } + + try { + const [startH, startM] = startTime.split(':').map(Number); + const [endH, endM] = endTime.split(':').map(Number); + + let startMinutes = startH * 60 + startM; + let endMinutes = endH * 60 + endM; + + // 处理跨日情况 + if (endMinutes < startMinutes) { + endMinutes += 24 * 60; + } + + const diffMinutes = endMinutes - startMinutes; + const hours = Math.floor(diffMinutes / 60); + const minutes = diffMinutes % 60; + + return `${hours}:${minutes.toString().padStart(2, '0')}`; + } catch (error) { + console.error('计算工时失败:', error); + return '0:00'; + } +} + +// 星期中文显示 +function getDayOfWeekChinese(dateString) { + const date = new Date(dateString); + const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + return days[date.getDay()]; +} + +// 判断是否为今天 +function isToday(dateString) { + const today = new Date().toISOString().split('T')[0]; + return dateString === today; +} + +// 文件下载 +function downloadFile(content, filename, type = 'text/plain') { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// CSV 生成 +function generateCSV(data, headers) { + const csvContent = [ + headers.join(','), + ...data.map(row => headers.map(header => { + const value = row[header] || ''; + // 如果值包含逗号、引号或换行符,需要用引号包围 + if (value.toString().includes(',') || value.toString().includes('"') || value.toString().includes('\n')) { + return `"${value.toString().replace(/"/g, '""')}"`; + } + return value; + }).join(',')) + ].join('\n'); + + return csvContent; +} + +// 页面加载完成后的初始化 +document.addEventListener('DOMContentLoaded', function() { + // 添加点击外部关闭模态框的功能 + document.addEventListener('click', function(e) { + if (e.target.classList.contains('modal')) { + e.target.classList.remove('show'); + e.target.style.display = 'none'; + } + }); + + // 添加ESC键关闭模态框的功能 + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + const openModal = document.querySelector('.modal.show'); + if (openModal) { + openModal.classList.remove('show'); + openModal.style.display = 'none'; + } + } + }); +}); + +// 添加通知样式到头部(如果不存在) +if (!document.querySelector('#notification-styles')) { + const style = document.createElement('style'); + style.id = 'notification-styles'; + style.textContent = ` + .notification { + position: fixed; + top: 20px; + right: 20px; + padding: 15px 20px; + border-radius: 5px; + color: white; + z-index: 10000; + display: flex; + align-items: center; + gap: 15px; + max-width: 400px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + animation: slideIn 0.3s ease-out; + } + + .notification.success { + background-color: #27ae60; + } + + .notification.error { + background-color: #e74c3c; + } + + .notification.info { + background-color: #3498db; + } + + .notification button { + background: none; + border: none; + color: white; + font-size: 18px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + } + + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + `; + document.head.appendChild(style); +} \ No newline at end of file diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..b27015a --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,210 @@ +// 首页面板JavaScript + +// 页面加载时初始化 +document.addEventListener('DOMContentLoaded', function() { + loadDashboardStats(); + loadRecentRecords(); +}); + +// 加载仪表板统计数据 +async function loadDashboardStats() { + try { + // 并行加载项目和工时统计 + const [projectsResponse, recentRecordsResponse] = await Promise.all([ + apiGet('/api/projects'), + loadThisWeekHours() + ]); + + // 更新活跃项目数 + const activeProjects = projectsResponse.data.filter(p => p.is_active).length; + updateStatValue('total-projects', activeProjects); + + // 更新本周工时 + updateStatValue('this-week-hours', recentRecordsResponse.weeklyHours || '0:00'); + + // 加载本月记录数 + const thisMonthCount = await loadThisMonthRecordsCount(); + updateStatValue('this-month-records', thisMonthCount); + + } catch (error) { + console.error('加载仪表板统计失败:', error); + // 显示默认值 + updateStatValue('total-projects', '0'); + updateStatValue('this-week-hours', '0:00'); + updateStatValue('this-month-records', '0'); + } +} + +// 更新统计值 +function updateStatValue(elementId, value) { + const element = document.getElementById(elementId); + if (element) { + element.textContent = value; + } +} + +// 加载本周工时 +async function loadThisWeekHours() { + try { + const weekRange = getThisWeekRange(); + const url = `/api/timerecords?start_date=${weekRange.start}&end_date=${weekRange.end}`; + const response = await apiGet(url); + + // 计算本周总工时 + let totalHours = 0; + response.data.forEach(record => { + if (record.hours && record.hours !== '-') { + totalHours += hoursToDecimal(record.hours); + } + }); + + return { + weeklyHours: decimalToHours(totalHours), + recordCount: response.data.length + }; + } catch (error) { + console.error('加载本周工时失败:', error); + return { weeklyHours: '0:00', recordCount: 0 }; + } +} + +// 加载本月记录数 +async function loadThisMonthRecordsCount() { + try { + const today = new Date(); + const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + const startDate = firstDayOfMonth.toISOString().split('T')[0]; + const endDate = lastDayOfMonth.toISOString().split('T')[0]; + + const url = `/api/timerecords?start_date=${startDate}&end_date=${endDate}`; + const response = await apiGet(url); + + return response.data.length; + } catch (error) { + console.error('加载本月记录数失败:', error); + return 0; + } +} + +// 加载最近记录 +async function loadRecentRecords() { + try { + // 获取最近7天的记录 + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - 6); // 最近7天 + + const url = `/api/timerecords?start_date=${startDate.toISOString().split('T')[0]}&end_date=${endDate.toISOString().split('T')[0]}`; + const response = await apiGet(url); + + renderRecentRecords(response.data.slice(0, 5)); // 只显示最近5条 + } catch (error) { + console.error('加载最近记录失败:', error); + const container = document.getElementById('recent-records-list'); + if (container) { + container.innerHTML = '

加载失败

'; + } + } +} + +// 渲染最近记录 +function renderRecentRecords(records) { + const container = document.getElementById('recent-records-list'); + if (!container) return; + + if (records.length === 0) { + container.innerHTML = '

暂无最近记录

'; + return; + } + + container.innerHTML = ` +
+ + + + + + + + + + + ${records.map(record => { + const projectDisplay = record.project + ? (record.project.project_type === 'traditional' + ? `${record.project.customer_name} ${record.project.project_code}` + : `${record.project.customer_name} ${record.project.contract_number}`) + : '-'; + + const rowClass = getRowClass(record); + + return ` + + + + + + + `; + }).join('')} + +
日期事件项目工时
+ ${formatDate(record.date)} + ${isToday(record.date) ? '今天' : ''} + ${escapeHtml(record.event_description || '-')}${escapeHtml(projectDisplay)}${record.hours || '-'}
+
+ `; +} + +// 获取行的CSS类名(根据是否为休息日) +function getRowClass(record) { + if (record.is_holiday) { + if (record.is_working_on_holiday) { + return 'working-holiday-row'; // 休息日工作 + } else { + return 'holiday-row'; // 休息日休息 + } + } + return ''; +} + +// HTML转义函数 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// 添加今天徽章样式 +if (!document.querySelector('#today-badge-styles')) { + const style = document.createElement('style'); + style.id = 'today-badge-styles'; + style.textContent = ` + .today-badge { + background-color: #3498db; + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + margin-left: 5px; + } + + .recent-records-table { + overflow-x: auto; + } + + .recent-records-table .data-table { + margin-bottom: 0; + } + + .recent-records-table .data-table td { + font-size: 13px; + padding: 10px 8px; + } + `; + document.head.appendChild(style); +} \ No newline at end of file diff --git a/static/js/projects.js b/static/js/projects.js new file mode 100644 index 0000000..ac79014 --- /dev/null +++ b/static/js/projects.js @@ -0,0 +1,386 @@ +// 项目管理页面JavaScript + +let projects = []; +let currentEditingProject = null; + +// 页面加载时初始化 +document.addEventListener('DOMContentLoaded', function() { + loadProjects(); + setupEventListeners(); +}); + +// 设置事件监听器 +function setupEventListeners() { + // 项目表单提交 + const projectForm = document.getElementById('project-form'); + if (projectForm) { + projectForm.addEventListener('submit', handleProjectSubmit); + } +} + +// 加载项目列表 +async function loadProjects() { + try { + const response = await apiGet('/api/projects'); + projects = response.data; + renderProjectsTable(); + updateProjectStats(); + } catch (error) { + showError('加载项目失败: ' + error.message); + console.error('加载项目失败:', error); + } +} + +// 渲染项目表格 +function renderProjectsTable() { + const tbody = document.getElementById('projects-tbody'); + if (!tbody) return; + + if (projects.length === 0) { + tbody.innerHTML = '暂无项目数据'; + return; + } + + tbody.innerHTML = projects.map(project => ` + + ${escapeHtml(project.project_name)} + + + ${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'} + + + ${escapeHtml(project.customer_name)} + + ${project.project_type === 'traditional' + ? escapeHtml(project.project_code) + : escapeHtml(project.contract_number || 'PSI-PROJ')} + + + + ${project.is_active ? '活跃' : '禁用'} + + + ${formatDate(project.start_date)} + ${formatDateTime(project.created_at)} + + + + + + `).join(''); +} + +// 更新项目统计 +function updateProjectStats() { + const totalCount = projects.length; + const traditionalCount = projects.filter(p => p.project_type === 'traditional').length; + const psiCount = projects.filter(p => p.project_type === 'psi').length; + + const totalElement = document.getElementById('total-projects-count'); + const traditionalElement = document.getElementById('traditional-projects-count'); + const psiElement = document.getElementById('psi-projects-count'); + + if (totalElement) totalElement.textContent = totalCount; + if (traditionalElement) traditionalElement.textContent = traditionalCount; + if (psiElement) psiElement.textContent = psiCount; +} + +// 筛选项目 +function filterProjects() { + const typeFilter = document.getElementById('project-type-filter').value; + + let filteredProjects = projects; + if (typeFilter) { + filteredProjects = projects.filter(p => p.project_type === typeFilter); + } + + renderFilteredProjects(filteredProjects); +} + +// 渲染筛选后的项目 +function renderFilteredProjects(filteredProjects) { + const tbody = document.getElementById('projects-tbody'); + if (!tbody) return; + + if (filteredProjects.length === 0) { + tbody.innerHTML = '没有符合条件的项目'; + return; + } + + tbody.innerHTML = filteredProjects.map(project => ` + + ${escapeHtml(project.project_name)} + + + ${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'} + + + ${escapeHtml(project.customer_name)} + + ${project.project_type === 'traditional' + ? escapeHtml(project.project_code) + : escapeHtml(project.contract_number || 'PSI-PROJ')} + + + + ${project.is_active ? '活跃' : '禁用'} + + + ${formatDate(project.start_date)} + ${formatDateTime(project.created_at)} + + + + + + `).join(''); +} + +// 显示创建项目模态框 +function showCreateProjectModal() { + currentEditingProject = null; + resetForm('project-form'); + document.getElementById('modal-title').textContent = '新建项目'; + + // 隐藏项目类型特定字段 + document.getElementById('traditional-fields').style.display = 'none'; + document.getElementById('psi-fields').style.display = 'none'; + + showModal('create-project-modal'); +} + +// 项目类型切换 +function toggleProjectFields() { + const projectType = document.getElementById('project_type').value; + const traditionalFields = document.getElementById('traditional-fields'); + const psiFields = document.getElementById('psi-fields'); + const projectCodeField = document.getElementById('project_code'); + const contractNumberField = document.getElementById('contract_number'); + + if (projectType === 'traditional') { + traditionalFields.style.display = 'block'; + psiFields.style.display = 'none'; + projectCodeField.required = true; + contractNumberField.required = false; + } else if (projectType === 'psi') { + traditionalFields.style.display = 'none'; + psiFields.style.display = 'block'; + projectCodeField.required = false; + contractNumberField.required = true; + } else { + traditionalFields.style.display = 'none'; + psiFields.style.display = 'none'; + projectCodeField.required = false; + contractNumberField.required = false; + } +} + +// 处理项目表单提交 +async function handleProjectSubmit(e) { + e.preventDefault(); + + const formData = getFormData('project-form'); + + try { + let response; + if (currentEditingProject) { + // 更新项目 + response = await apiPut(`/api/projects/${currentEditingProject.id}`, formData); + } else { + // 创建新项目 + response = await apiPost('/api/projects', formData); + } + + showSuccess(currentEditingProject ? '项目更新成功' : '项目创建成功'); + closeModal('create-project-modal'); + loadProjects(); // 重新加载项目列表 + } catch (error) { + showError(error.message); + } +} + +// 编辑项目 +function editProject(projectId) { + const project = projects.find(p => p.id === projectId); + if (!project) { + showError('项目不存在'); + return; + } + + currentEditingProject = project; + document.getElementById('modal-title').textContent = '编辑项目'; + + // 填充表单数据 + fillForm('project-form', project); + + // 设置项目类型并显示对应字段 + document.getElementById('project_type').value = project.project_type; + toggleProjectFields(); + + showModal('create-project-modal'); +} + +// 删除项目 +async function deleteProject(projectId) { + const project = projects.find(p => p.id === projectId); + if (!project) { + showError('项目不存在'); + return; + } + + if (!confirm(`确定要删除项目"${project.project_name}"吗?`)) { + return; + } + + try { + await apiDelete(`/api/projects/${projectId}`); + showSuccess('项目删除成功'); + loadProjects(); // 重新加载项目列表 + } catch (error) { + showError('删除项目失败: ' + error.message); + } +} + +// 显示导入模态框 +function showImportModal() { + showModal('import-modal'); +} + +// 下载模板文件 +function downloadTemplate(type) { + let csvContent = ''; + + if (type === 'traditional') { + csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述 +CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目 +Project Alpha,traditional,客户A,01A-DEV,,Alpha开发项目`; + } else if (type === 'psi') { + csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述 +NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目 +Samsung项目,psi,Samsung,PSI-PROJ,SC20241201,Samsung客户项目`; + } else if (type === 'mixed') { + csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述 +CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目 +NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目 +Project Beta,traditional,客户B,01B-TEST,,Beta测试项目`; + } + + downloadFile(csvContent, `项目模板_${type}.csv`, 'text/csv;charset=utf-8'); +} + +// 导入项目 +async function importProjects() { + const fileInput = document.getElementById('import-file'); + const file = fileInput.files[0]; + + if (!file) { + showError('请选择CSV文件'); + return; + } + + if (!file.name.endsWith('.csv')) { + showError('请选择CSV文件'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/api/projects/import', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || '导入失败'); + } + + // 显示导入结果 + showImportResult(result); + closeModal('import-modal'); + loadProjects(); // 重新加载项目列表 + } catch (error) { + showError('导入项目失败: ' + error.message); + } +} + +// 显示导入结果 +function showImportResult(result) { + const content = document.getElementById('import-result-content'); + + let html = `
+

导入完成

+

成功导入 ${result.created_count} 个项目

+
`; + + if (result.errors && result.errors.length > 0) { + html += `
+
导入错误 (${result.errors.length} 项):
+
    `; + + result.errors.forEach(error => { + html += `
  • ${escapeHtml(error)}
  • `; + }); + + html += `
`; + } + + content.innerHTML = html; + showModal('import-result-modal'); +} + +// HTML转义函数 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// 添加徽章样式(如果CSS中没有定义) +if (!document.querySelector('#badge-styles')) { + const style = document.createElement('style'); + style.id = 'badge-styles'; + style.textContent = ` + .badge { + display: inline-block; + padding: 4px 8px; + font-size: 12px; + font-weight: 600; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 4px; + } + + .badge-primary { + background-color: #3498db; + color: white; + } + + .badge-secondary { + background-color: #6c757d; + color: white; + } + + .badge-success { + background-color: #27ae60; + color: white; + } + + .badge-danger { + background-color: #e74c3c; + color: white; + } + + .btn-sm { + padding: 6px 12px; + font-size: 12px; + } + `; + document.head.appendChild(style); +} \ No newline at end of file diff --git a/static/js/statistics.js b/static/js/statistics.js new file mode 100644 index 0000000..dd6dae0 --- /dev/null +++ b/static/js/statistics.js @@ -0,0 +1,485 @@ +// 统计分析页面JavaScript + +let cutoffPeriods = []; +let currentStats = null; +let projectHoursChart = null; // 用于存储图表实例 + +// 页面加载时初始化 +document.addEventListener('DOMContentLoaded', function() { + loadCutoffPeriods(); + setupEventListeners(); +}); + +// 设置事件监听器 +function setupEventListeners() { + // 周期表单提交 + const periodForm = document.getElementById('period-form'); + if (periodForm) { + periodForm.addEventListener('submit', handlePeriodSubmit); + } +} + +// 加载Cut-Off周期 +async function loadCutoffPeriods() { + try { + const response = await apiGet('/api/statistics/periods'); + cutoffPeriods = response.data; + populatePeriodSelect(); + } catch (error) { + showError('加载周期列表失败: ' + error.message); + console.error('加载周期列表失败:', error); + } +} + +// 填充周期选择框 +function populatePeriodSelect() { + const select = document.getElementById('period-select'); + if (!select) return; + + // 清空现有选项(保留第一个默认选项) + const firstOption = select.querySelector('option'); + select.innerHTML = ''; + if (firstOption) { + select.appendChild(firstOption); + } + + // 添加周期选项 + cutoffPeriods.forEach(period => { + const option = document.createElement('option'); + option.value = period.id; + option.textContent = `${period.period_name} (${formatDate(period.start_date)} - ${formatDate(period.end_date)})`; + select.appendChild(option); + }); +} + +// 加载周统计数据 +async function loadWeeklyStats() { + const periodId = document.getElementById('period-select').value; + + if (!periodId) { + hideStatsDisplay(); + return; + } + + try { + const response = await apiGet(`/api/statistics/weekly?period_id=${periodId}`); + currentStats = response.data; + displayStats(); + } catch (error) { + showError('加载统计数据失败: ' + error.message); + console.error('加载统计数据失败:', error); + } +} + +// 加载自定义日期范围统计 +async function loadCustomStats() { + const startDate = document.getElementById('custom-start-date').value; + const endDate = document.getElementById('custom-end-date').value; + + if (!startDate || !endDate) { + showError('请选择开始和结束日期'); + return; + } + + if (new Date(startDate) > new Date(endDate)) { + showError('开始日期不能晚于结束日期'); + return; + } + + try { + const response = await apiGet(`/api/statistics/weekly?start_date=${startDate}&end_date=${endDate}`); + currentStats = response.data; + displayStats(); + + // 清空周期选择 + document.getElementById('period-select').value = ''; + } catch (error) { + showError('加载统计数据失败: ' + error.message); + console.error('加载统计数据失败:', error); + } +} + +// 显示统计数据 +function displayStats() { + if (!currentStats) return; + + showStatsDisplay(); + updateStatsOverview(); + renderDailyDetails(); + renderProjectDistribution(); +} + +// 显示统计界面 +function showStatsDisplay() { + document.getElementById('stats-overview').style.display = 'block'; + document.getElementById('daily-details').style.display = 'block'; + document.getElementById('project-distribution').style.display = 'block'; +} + +// 隐藏统计界面 +function hideStatsDisplay() { + document.getElementById('stats-overview').style.display = 'none'; + document.getElementById('daily-details').style.display = 'none'; + document.getElementById('project-distribution').style.display = 'none'; +} + +// 更新统计概览 +function updateStatsOverview() { + if (!currentStats) return; + + // 更新周期信息 + document.getElementById('current-period-name').textContent = currentStats.period.period_name; + document.getElementById('period-date-range').textContent = + `${formatDate(currentStats.period.start_date)} - ${formatDate(currentStats.period.end_date)}`; + + // 更新统计数据 + document.getElementById('workday-total').textContent = currentStats.workday_total; + document.getElementById('holiday-total').textContent = currentStats.holiday_total; + document.getElementById('weekly-total').textContent = currentStats.weekly_total; + document.getElementById('completion-rate').textContent = `${currentStats.completion_rate}%`; + + // 更新工作天数统计 + document.getElementById('working-days').textContent = currentStats.working_days; + document.getElementById('holiday-work-days').textContent = currentStats.holiday_work_days; + document.getElementById('rest-days').textContent = currentStats.rest_days; + + // 更新完成度颜色 + const completionElement = document.getElementById('completion-rate'); + completionElement.className = 'stat-value'; + if (currentStats.completion_rate >= 100) { + completionElement.style.color = '#27ae60'; + } else if (currentStats.completion_rate >= 80) { + completionElement.style.color = '#f39c12'; + } else { + completionElement.style.color = '#e74c3c'; + } +} + +// 渲染每日详情 +function renderDailyDetails() { + if (!currentStats || !currentStats.daily_records) return; + + const tbody = document.getElementById('daily-stats-tbody'); + if (!tbody) return; + + tbody.innerHTML = currentStats.daily_records.map(record => { + const rowClass = getRowClass(record); + + return ` + + ${formatDate(record.date)} + ${record.day_of_week} + ${escapeHtml(record.event)} + ${escapeHtml(record.project)} + ${record.start_time} + ${record.end_time} + ${escapeHtml(record.activity_num)} + ${record.hours} + + `; + }).join(''); +} + +// 渲染项目工时分布图表和表格 +function renderProjectDistribution() { + if (!currentStats || !currentStats.project_hours) return; + + const projectData = currentStats.project_hours; + const tableBody = document.getElementById('project-hours-tbody'); + const ctx = document.getElementById('project-hours-chart').getContext('2d'); + + if (!ctx || !tableBody) return; + + // 清理旧内容 + tableBody.innerHTML = ''; + if (projectHoursChart) { + projectHoursChart.destroy(); + } + + if (projectData.length === 0) { + // 在表格中显示无数据信息 + tableBody.innerHTML = '暂无项目工时数据'; + + // 清理画布 + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.font = "16px Arial"; + ctx.fillStyle = "#888"; + ctx.textAlign = "center"; + ctx.fillText("暂无项目工时数据", ctx.canvas.width / 2, 50); + return; + } + + // 填充表格 + projectData.forEach(item => { + const row = ` + + ${escapeHtml(item.project)} + ${item.hours} + ${item.percentage}% + + `; + tableBody.innerHTML += row; + }); + + // 准备图表数据 + const labels = projectData.map(item => item.project); + const data = projectData.map(item => parseFloat(item.hours.replace(':', '.'))); + + const backgroundColors = [ + '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', + '#E7E9ED', '#8DDF3C', '#FFD700', '#B22222', '#4682B4', '#D2B48C' + ]; + + // 渲染图表 + projectHoursChart = new Chart(ctx, { + type: 'pie', + data: { + labels: labels, + datasets: [{ + label: '工时', + data: data, + backgroundColor: backgroundColors.slice(0, data.length), + borderColor: '#fff', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + }, + title: { + display: false, // 标题可以省略,因为旁边有表格 + }, + tooltip: { + callbacks: { + label: function(context) { + let label = context.label || ''; + if (label) { + label += ': '; + } + if (context.parsed !== null) { + const total = context.dataset.data.reduce((a, b) => a + b, 0); + const percentage = ((context.parsed / total) * 100).toFixed(2); + label += `${context.raw} 小时 (${percentage}%)`; + } + return label; + } + } + } + } + } + }); +} + +// 获取行的CSS类名(根据是否为休息日) +function getRowClass(record) { + if (record.is_holiday) { + if (record.is_working_on_holiday) { + return 'working-holiday-row'; // 休息日工作 + } else { + return 'holiday-row'; // 休息日休息 + } + } + return ''; +} + +// 显示创建周期模态框 +function showCreatePeriodModal() { + resetForm('period-form'); + showModal('create-period-modal'); +} + +// 计算周期信息 +function calculatePeriodInfo() { + const startDate = document.getElementById('start_date').value; + const endDate = document.getElementById('end_date').value; + + if (!startDate || !endDate) return; + + const start = new Date(startDate); + const end = new Date(endDate); + + if (start >= end) { + showError('开始日期必须早于结束日期'); + return; + } + + // 计算天数和周数 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + const weeks = Math.round(daysDiff / 7); + + // 更新周数字段 + document.getElementById('weeks').value = weeks; + + // 更新目标工时(如果为空) + const targetHoursField = document.getElementById('target_hours'); + if (!targetHoursField.value) { + targetHoursField.value = weeks * 40; // 默认每周40小时 + } +} + +// 应用周期模板 +function applyTemplate(templateType) { + const today = new Date(); + let startDate, endDate, weeks, targetHours, periodName; + + switch (templateType) { + case 'weekly': + // 本周周期 + const dayOfWeek = today.getDay(); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - dayOfWeek + 1); // 周一 + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); // 周日 + + startDate = startOfWeek.toISOString().split('T')[0]; + endDate = endOfWeek.toISOString().split('T')[0]; + weeks = 1; + targetHours = 40; + periodName = `${today.getFullYear()}年第${getWeekNumber(today)}周`; + break; + + case 'biweekly': + // 双周周期 + startDate = getTodayString(); + const biweekEnd = new Date(today); + biweekEnd.setDate(today.getDate() + 13); + endDate = biweekEnd.toISOString().split('T')[0]; + weeks = 2; + targetHours = 80; + periodName = `双周周期 ${formatDate(startDate)}-${formatDate(endDate)}`; + break; + + case 'four-weeks': + // 4周周期 + startDate = getTodayString(); + const fourWeeksEnd = new Date(today); + fourWeeksEnd.setDate(today.getDate() + 27); + endDate = fourWeeksEnd.toISOString().split('T')[0]; + weeks = 4; + targetHours = 160; + periodName = `4周周期 ${formatDate(startDate)}-${formatDate(endDate)}`; + break; + + case 'five-weeks': + // 5周周期 + startDate = getTodayString(); + const fiveWeeksEnd = new Date(today); + fiveWeeksEnd.setDate(today.getDate() + 34); + endDate = fiveWeeksEnd.toISOString().split('T')[0]; + weeks = 5; + targetHours = 200; + periodName = `5周周期 ${formatDate(startDate)}-${formatDate(endDate)}`; + break; + + default: + // 默认行为可以指向一个常用模板,例如4周 + startDate = getTodayString(); + const defaultEnd = new Date(today); + defaultEnd.setDate(today.getDate() + 27); + endDate = defaultEnd.toISOString().split('T')[0]; + weeks = 4; + targetHours = 160; + periodName = `4周周期 ${formatDate(startDate)}-${formatDate(endDate)}`; + break; + } + + // 填充表单 + document.getElementById('period_name').value = periodName; + document.getElementById('start_date').value = startDate; + document.getElementById('end_date').value = endDate; + document.getElementById('weeks').value = weeks; + document.getElementById('target_hours').value = targetHours; +} + +// 获取周数 +function getWeekNumber(date) { + const firstDayOfYear = new Date(date.getFullYear(), 0, 1); + const pastDaysOfYear = (date - firstDayOfYear) / 86400000; + return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); +} + +// 处理周期表单提交 +async function handlePeriodSubmit(e) { + e.preventDefault(); + + const formData = getFormData('period-form'); + + // 验证必填字段 + if (!formData.period_name || !formData.start_date || !formData.end_date) { + showError('请填写完整的周期信息'); + return; + } + + try { + const response = await apiPost('/api/statistics/periods', formData); + showSuccess('Cut-Off周期创建成功'); + closeModal('create-period-modal'); + loadCutoffPeriods(); // 重新加载周期列表 + } catch (error) { + showError(error.message); + } +} + +// 管理周期 +function managePeriods() { + loadPeriodsTable(); + showModal('manage-periods-modal'); +} + +// 加载周期管理表格 +function loadPeriodsTable() { + const tbody = document.getElementById('periods-tbody'); + if (!tbody) return; + + if (cutoffPeriods.length === 0) { + tbody.innerHTML = '暂无周期数据'; + return; + } + + tbody.innerHTML = cutoffPeriods.map(period => ` + + ${escapeHtml(period.period_name)} + ${formatDate(period.start_date)} + ${formatDate(period.end_date)} + ${period.weeks} + ${period.target_hours}小时 + + + + + `).join(''); +} + +// 删除周期 +async function deletePeriod(periodId) { + const period = cutoffPeriods.find(p => p.id === periodId); + if (!period) { + showError('周期不存在'); + return; + } + + if (!confirm(`确定要删除周期"${period.period_name}"吗?`)) { + return; + } + + try { + await apiDelete(`/api/statistics/periods/${periodId}`); + showSuccess('周期删除成功'); + loadCutoffPeriods(); // 重新加载周期列表 + loadPeriodsTable(); // 更新管理表格 + } catch (error) { + showError('删除周期失败: ' + error.message); + } +} + +// HTML转义函数 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} \ No newline at end of file diff --git a/static/js/timerecords.js b/static/js/timerecords.js new file mode 100644 index 0000000..d1f21e4 --- /dev/null +++ b/static/js/timerecords.js @@ -0,0 +1,347 @@ +// 工时记录页面JavaScript + +let timeRecords = []; +let projects = []; +let currentEditingRecord = null; + +// 页面加载时初始化 +document.addEventListener('DOMContentLoaded', function() { + initializePage(); + setupEventListeners(); +}); + +// 初始化页面 +async function initializePage() { + await Promise.all([ + loadProjects(), + loadTimeRecords() + ]); + + // 设置默认筛选日期为本周 + const weekRange = getThisWeekRange(); + document.getElementById('filter-start-date').value = weekRange.start; + document.getElementById('filter-end-date').value = weekRange.end; + + // 设置默认记录日期为今天 + document.getElementById('record_date').value = getTodayString(); +} + +// 设置事件监听器 +function setupEventListeners() { + // 工时记录表单提交 + const recordForm = document.getElementById('timerecord-form'); + if (recordForm) { + recordForm.addEventListener('submit', handleRecordSubmit); + } + + // 时间输入变化时自动计算工时 + const startTimeInput = document.getElementById('start_time'); + const endTimeInput = document.getElementById('end_time'); + + if (startTimeInput) { + startTimeInput.addEventListener('change', calculateHours); + } + if (endTimeInput) { + endTimeInput.addEventListener('change', calculateHours); + } +} + +// 加载项目列表 +async function loadProjects() { + try { + const response = await apiGet('/api/projects'); + projects = response.data; + populateProjectSelect(); + } catch (error) { + showError('加载项目失败: ' + error.message); + console.error('加载项目失败:', error); + } +} + +// 填充项目选择框 +function populateProjectSelect() { + const selects = ['project_id', 'filter-project']; + + selects.forEach(selectId => { + const select = document.getElementById(selectId); + if (!select) return; + + // 清空现有选项(保留第一个默认选项) + const firstOption = select.querySelector('option'); + select.innerHTML = ''; + if (firstOption) { + select.appendChild(firstOption); + } + + // 添加项目选项 + projects.forEach(project => { + const option = document.createElement('option'); + option.value = project.id; + option.textContent = project.project_name; + select.appendChild(option); + }); + }); +} + +// 加载工时记录 +async function loadTimeRecords() { + try { + const params = new URLSearchParams(); + + const startDate = document.getElementById('filter-start-date').value; + const endDate = document.getElementById('filter-end-date').value; + const projectId = document.getElementById('filter-project').value; + + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + if (projectId) params.append('project_id', projectId); + + const url = `/api/timerecords${params.toString() ? '?' + params.toString() : ''}`; + const response = await apiGet(url); + timeRecords = response.data; + renderTimeRecordsTable(); + } catch (error) { + showError('加载工时记录失败: ' + error.message); + console.error('加载工时记录失败:', error); + } +} + +// 渲染工时记录表格 +function renderTimeRecordsTable() { + const tbody = document.getElementById('timerecords-tbody'); + if (!tbody) return; + + if (timeRecords.length === 0) { + tbody.innerHTML = '暂无工时记录'; + return; + } + + tbody.innerHTML = timeRecords.map(record => { + const projectDisplay = record.project ? record.project.project_name : '-'; + + const rowClass = getRowClass(record); + + return ` + + ${formatDate(record.date)} + ${record.day_of_week || getDayOfWeekChinese(record.date)} + ${escapeHtml(record.event_description || '-')} + ${escapeHtml(projectDisplay)} + ${record.start_time || '-'} + ${record.end_time || '-'} + ${escapeHtml(record.activity_num || '-')} + ${record.hours || '-'} + + + + + + `; + }).join(''); +} + +// 获取行的CSS类名(根据是否为休息日) +function getRowClass(record) { + if (record.is_holiday) { + if (record.is_working_on_holiday) { + return 'working-holiday-row'; // 休息日工作 + } else { + return 'holiday-row'; // 休息日休息 + } + } + return ''; +} + +// 重置筛选条件 +function resetFilters() { + document.getElementById('filter-start-date').value = ''; + document.getElementById('filter-end-date').value = ''; + document.getElementById('filter-project').value = ''; + loadTimeRecords(); +} + +// 显示创建记录模态框 +function showCreateRecordModal() { + currentEditingRecord = null; + resetForm('timerecord-form'); + document.getElementById('timerecord-modal-title').textContent = '新建工时记录'; + + // 设置默认日期为今天 + document.getElementById('record_date').value = getTodayString(); + + // 设置默认时间 + document.getElementById('start_time').value = '09:00'; + document.getElementById('end_time').value = '17:00'; + + // 自动计算工时 + updateHoursInput(); + + // 隐藏休息日信息和警告 + document.getElementById('holiday-info').style.display = 'none'; + document.getElementById('holiday-warning').style.display = 'none'; + + showModal('timerecord-modal'); + + // 检查今天是否为休息日 + checkHoliday(); +} + +// 检查休息日 +async function checkHoliday() { + const dateInput = document.getElementById('record_date'); + const date = dateInput.value; + + if (!date) return; + + try { + const response = await apiGet(`/api/timerecords/check_holiday/${date}`); + const holidayInfo = response.data; + + updateHolidayInfo(holidayInfo); + } catch (error) { + console.error('检查休息日失败:', error); + // 如果API调用失败,使用本地判断 + const dayOfWeek = new Date(date).getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + updateHolidayInfo({ + is_holiday: isWeekend, + holiday_type: isWeekend ? 'weekend' : null, + holiday_name: null, + day_of_week: getDayOfWeekChinese(date) + }); + } +} + +// 更新休息日信息显示 +function updateHolidayInfo(holidayInfo) { + const holidayInfoDiv = document.getElementById('holiday-info'); + const holidayBadge = document.getElementById('holiday-badge'); + const holidayText = document.getElementById('holiday-text'); + const holidayWarning = document.getElementById('holiday-warning'); + + if (holidayInfo.is_holiday) { + // 显示休息日信息 + holidayInfoDiv.style.display = 'flex'; + holidayWarning.style.display = 'block'; + + // 设置徽章样式和文本 + if (holidayInfo.holiday_type === 'weekend') { + holidayBadge.className = 'badge weekend'; + holidayBadge.textContent = '周末'; + } else if (holidayInfo.holiday_type === 'national_holiday') { + holidayBadge.className = 'badge national-holiday'; + holidayBadge.textContent = '节假日'; + } else { + holidayBadge.className = 'badge weekend'; + holidayBadge.textContent = '休息日'; + } + + holidayText.textContent = holidayInfo.holiday_name || `${holidayInfo.day_of_week} 休息日`; + } else { + // 隐藏休息日信息 + holidayInfoDiv.style.display = 'none'; + holidayWarning.style.display = 'none'; + } +} + +// 更新工时输入框 +function updateHoursInput() { + const startTime = document.getElementById('start_time').value; + const endTime = document.getElementById('end_time').value; + const hoursField = document.getElementById('hours'); + + if (startTime && endTime) { + const calculated = calculateHours(startTime, endTime); // 调用 common.js 中的全局函数 + hoursField.value = calculated; + } else { + hoursField.value = ''; + } +} + +// 处理记录表单提交 +async function handleRecordSubmit(e) { + e.preventDefault(); + + const formData = getFormData('timerecord-form'); + + // 验证必填字段 + if (!formData.date) { + showError('请选择日期'); + return; + } + + try { + let response; + if (currentEditingRecord) { + // 更新记录 + response = await apiPut(`/api/timerecords/${currentEditingRecord.id}`, formData); + } else { + // 创建新记录 + response = await apiPost('/api/timerecords', formData); + } + + showSuccess(currentEditingRecord ? '工时记录更新成功' : '工时记录创建成功'); + closeModal('timerecord-modal'); + location.reload(); // 刷新页面以显示最新数据 + } catch (error) { + showError(error.message); + } +} + +// 编辑记录 +function editRecord(recordId) { + const record = timeRecords.find(r => r.id === recordId); + if (!record) { + showError('记录不存在'); + return; + } + + currentEditingRecord = record; + document.getElementById('timerecord-modal-title').textContent = '编辑工时记录'; + + // 填充表单数据 + fillForm('timerecord-form', { + date: record.date, + event_description: record.event_description || '', + project_id: record.project_id || '', + start_time: record.start_time || '', + end_time: record.end_time || '', + activity_num: record.activity_num || '', + hours: record.hours || '' + }); + + showModal('timerecord-modal'); + + // 检查是否为休息日 + checkHoliday(); +} + +// 删除记录 +async function deleteRecord(recordId) { + const record = timeRecords.find(r => r.id === recordId); + if (!record) { + showError('记录不存在'); + return; + } + + if (!confirm(`确定要删除这条工时记录吗?`)) { + return; + } + + try { + await apiDelete(`/api/timerecords/${recordId}`); + showSuccess('工时记录删除成功'); + location.reload(); // 刷新页面以显示最新数据 + } catch (error) { + showError('删除工时记录失败: ' + error.message); + } +} + +// HTML转义函数 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} \ No newline at end of file diff --git a/templates/import.html b/templates/import.html new file mode 100644 index 0000000..8c7cb4a --- /dev/null +++ b/templates/import.html @@ -0,0 +1,207 @@ + + + + + + 导入历史记录 - 个人工时记录系统 + + + + + +
+
+ + + +
+ + +
+ + +
+

历史记录

+
+ +

正在加载历史记录...

+
+
+
+
+ + + + diff --git a/templates/index.html b/templates/index.html index a26ec48..e270aef 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,6 +17,7 @@
  • 项目管理
  • 工时记录
  • 统计分析
  • +
  • 导入历史
  • diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..5f72d30 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,224 @@ + + + + + + 项目管理 - 个人工时记录系统 + + + + + +
    +
    + + + +
    +
    +
    0
    +
    总项目数
    +
    +
    +
    0
    +
    传统项目
    +
    +
    +
    0
    +
    PSI项目
    +
    +
    + + +
    + + + + + + + + + + + + + + + + + + +
    项目名称项目类型客户名标识码状态项目开始时间创建时间操作
    加载中...
    +
    +
    +
    + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/statistics.html b/templates/statistics.html new file mode 100644 index 0000000..f908b30 --- /dev/null +++ b/templates/statistics.html @@ -0,0 +1,234 @@ + + + + + + 统计分析 - 个人工时记录系统 + + + + + +
    +
    + + + +
    +
    +
    + + +
    +
    + +
    + + - + + +
    +
    +
    +
    + + + + + + + + + +
    +
    + + + + + + + + + + + \ No newline at end of file diff --git a/templates/timerecords.html b/templates/timerecords.html new file mode 100644 index 0000000..d60b69c --- /dev/null +++ b/templates/timerecords.html @@ -0,0 +1,167 @@ + + + + + + 工时记录 - 个人工时记录系统 + + + + + +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    + + + + + + + + + + + + + + + + + + + +
    日期星期事件项目开始时间结束时间Activity Num工时操作
    加载中...
    +
    +
    +
    + + + + + + + + \ No newline at end of file