From 8938ce27083968efd42d539e0f38a24c5d34f804 Mon Sep 17 00:00:00 2001 From: bf1942 Date: Thu, 4 Sep 2025 18:12:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api):=20=E9=87=8D=E6=9E=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=AE=BF=E9=97=AE=E4=B8=BASQLAlchemy?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E7=9A=84session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一移除手动创建的数据库session,统一使用models模块中的db.session - 修正项目创建接口,增加开始和结束日期的格式验证与处理 - 更新导入项目接口,使用枚举类型校验项目类型并优化异常处理 - 更新统计接口,避免多次查询假期数据,优化日期字符串处理 - 删除回滚前多余的session关闭调用,改为使用db.session.rollback() - app.py中重构数据库初始化:统一配置SQLAlchemy,动态创建数据库路径和表 - 项目模型新增开始日期和结束日期字段支持 - 添加导入批次历史记录模型支持 - 优化工具函数中日期类型提示,移除无用导入 - 更新requirements.txt依赖版本回退,确保兼容性 - 前端菜单添加导入历史导航入口,实现页面访问路由绑定 --- backend/__pycache__/app.cpython-313.pyc | Bin 0 -> 3092 bytes .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 136 bytes .../__pycache__/data_import.cpython-313.pyc | Bin 0 -> 5880 bytes .../api/__pycache__/projects.cpython-313.pyc | Bin 0 -> 11995 bytes .../__pycache__/statistics.cpython-313.pyc | Bin 0 -> 12920 bytes .../__pycache__/timerecords.cpython-313.pyc | Bin 0 -> 10102 bytes backend/api/data_import.py | 118 ++++ backend/api/projects.py | 103 ++-- backend/api/statistics.py | 64 +- backend/api/timerecords.py | 53 +- backend/app.py | 34 +- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 139 bytes .../models/__pycache__/models.cpython-313.pyc | Bin 0 -> 9203 bytes .../models/__pycache__/utils.cpython-313.pyc | Bin 0 -> 5425 bytes backend/models/models.py | 43 +- backend/models/utils.py | 3 +- data/timetrack.db | Bin 0 -> 36864 bytes requirements.txt | 8 +- static/css/styles.css | 580 ++++++++++++++++++ static/js/common.js | 373 +++++++++++ static/js/dashboard.js | 210 +++++++ static/js/projects.js | 386 ++++++++++++ static/js/statistics.js | 485 +++++++++++++++ static/js/timerecords.js | 347 +++++++++++ templates/import.html | 207 +++++++ templates/index.html | 1 + templates/projects.html | 224 +++++++ templates/statistics.html | 234 +++++++ templates/timerecords.html | 167 +++++ 29 files changed, 3490 insertions(+), 150 deletions(-) create mode 100644 backend/__pycache__/app.cpython-313.pyc create mode 100644 backend/api/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/api/__pycache__/data_import.cpython-313.pyc create mode 100644 backend/api/__pycache__/projects.cpython-313.pyc create mode 100644 backend/api/__pycache__/statistics.cpython-313.pyc create mode 100644 backend/api/__pycache__/timerecords.cpython-313.pyc create mode 100644 backend/api/data_import.py create mode 100644 backend/models/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/models/__pycache__/models.cpython-313.pyc create mode 100644 backend/models/__pycache__/utils.cpython-313.pyc create mode 100644 data/timetrack.db create mode 100644 static/css/styles.css create mode 100644 static/js/common.js create mode 100644 static/js/dashboard.js create mode 100644 static/js/projects.js create mode 100644 static/js/statistics.js create mode 100644 static/js/timerecords.js create mode 100644 templates/import.html create mode 100644 templates/projects.html create mode 100644 templates/statistics.html create mode 100644 templates/timerecords.html diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36c9fd3adc52ad7327592ab69c4ddc0b3fe49631 GIT binary patch literal 3092 zcmb_eUrZE77@xh}z1!RSvlOXTK)5Q2oYFf4P1Gt4pdQ>Q*WN-~0&zX=7C1TXuCq&2 zYTL7Ig0xNWp(dJ`=wnHn_^OGCFHQQ=2lQx4r;s*jU;2i{#H3ICW{+Vn7ShIaFE{he z{C+d@+nMkCX4iZ^8Nt)hb$|S#6QSQ|gFVo;p0!(0T}LWXxfqIan2S2FBS71Z7#|g| zVA*_3j5@K?vW1u{Dq+d8#h5!PW7)EuF;CQsy_W5Y`LIuwY7zDiE2{hGy9gzBA~fvZ zjSj6ZjH`yL5_{Gg6_qNh9@VS*j_?6A<{EPa{eMt2=pf>$Y|@w@3f6OJ9c!jOS;!_$ zJ;)KEJDyOXcBDt3^b~OZyq+=*ZKMEAznPiTv7XA~^m>oaFq3A+Ff%FI(U+=N9l4>w z!+-4ol-JP^$G{;6vs6xXsQeK=hlWJfH-HYUGXrRd?JY`347clq$F`aEFC%=6XV?Gx z2*sY8SwmIKV(<2RtqO=)id0#ZI8Di10svP zNdVSKG}=sN4F#CR_Ipd_?*Ghe+LTE*v-#(UX} zsUHo8!(@m0*6GgE-90DI3~DDj`#ZZj)sxylqW2VOs6hG?o!v3*O#DResow6+{@!?> zO1N;Ua{nRcpl$G9qi$K5eN>L^MH8gLzG*oFIq+g=+?>qLb65cOJX(Vik%kjThu(ZW{r-J1<_MBXv!&~-<5_NmrF{^ zg4BXvhMsA6nB~?NlkH=}E#;WLDyA`P&5mgt5s6Vq{bmvwx*xa|DnBYI;RPvNR_P5_ zr2H2XT2(Q_)>=IkEYi4{+zLXND)vKR?dwA0fs)d;Ahnexd&-_^-AON|Sg|j)tp&?A zi8O4c*T&nQliONS4lGCqEV;oNOg)uInwCpW>Y9e>MMxZZg9wGBIZlN0`Am+u(iu!~ z;!2JfRzD0F&17K!ktUN9I^YIzrt-Pb%owJ(CXq6^j7go6c%aY#GWCllk(t(PmQ-VX z3?dS~07kM?dI4v0CU(JCOfOqZ?{6aF{1ki`xCl>Yl4vMvuoeg!(Wnl*gHmg0v5OV~ zC=5DBp!}3QabwX!0n^=}QyZ5c*%Z;Ttb8dg1wu;=!6%}_v%lE=R7Rrv>b6gNpGv5z zdhWvPg}KYKmv435j@*pgj^B)b({(p8AGsT!k3Z;o7`Y#L7{4F?zNd6-pw#kqss5dX zL9L{mEA}i4&Y9T9v1`(ugO7#gWq;M&*zDL`b~bzC@SU?I|G{G9*U;g6JMOs`TDpqe zGtn#21*Q3s5L{|M%$ngxLhDl}5?$6r{Gw2^>O`_{u5q^U2Y2nVB+vOi^L?Q_!Yoer%;1&5tHvj1pEx-2wKX0&YgVac zWAB&5ItsQ@u%3dgHuzylY@lE}1sf^YZi63`#9b6@qhJFC+W_<`#pttkkP6!WGQ4mE z-&Svr`1tR9a-_~hJ#aFa$!S{uw5v5_{W4b}tps@@ruC7jF(QoT4HMilkIi5;5l87a zq~*O<)+Dmk0gi@^vJ?9z^JzV6gvwRfE4|4=Cd4w2?Z^U~* zwBF6$05Ocb-Kjw{7J6)yrtL;$2JqW61moNWALl~V&t*Vwkjf_){5M}0TK{% AqW}N^ literal 0 HcmV?d00001 diff --git a/backend/api/__pycache__/__init__.cpython-313.pyc b/backend/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..faa91f641ebf4fa524127cfe729f589c756e2861 GIT binary patch literal 136 zcmey&%ge<81c|mgGsJ=PV-N=h7@>^MEI`IohI9r^M!%H|MNB~6XOPq_br-9cfTH}Y z)Z~(wlFZ!Hn54wy?9{xJn8bq2nE3e2yv&mLc)fzkTO2mI`6;D2sdh!IKz$%{i$RQ! M%#4hTMa)1J0Dv1Fr2qf` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2db8c6a61e39700b6adf1e4d03e971c29dec1231 GIT binary patch literal 5880 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 N6yfYDk$4ge_#bS{H75W7 literal 0 HcmV?d00001 diff --git a/backend/api/__pycache__/projects.cpython-313.pyc b/backend/api/__pycache__/projects.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6a691e74ed4919188c6dead6f07c6e408c1c999 GIT binary patch literal 11995 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 literal 0 HcmV?d00001 diff --git a/backend/api/__pycache__/statistics.cpython-313.pyc b/backend/api/__pycache__/statistics.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..564ff974ea06edb12730a0744bf5c01c565a19ea GIT binary patch literal 12920 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 literal 0 HcmV?d00001 diff --git a/backend/api/__pycache__/timerecords.cpython-313.pyc b/backend/api/__pycache__/timerecords.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b7660d64bd8c0dcff359c4c2db5b59c506eace9f GIT binary patch literal 10102 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 G1pE)3x>;QS literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5febe3728f5d6a4b9195c3083f779641a176fa7 GIT binary patch literal 139 zcmey&%ge<81kAQOGo*m@V-N=h7@>^MEI`IohI9r^M!%H|MNB~6XOPq_Ef=epfTH}Y z)Z~(wlFZ!Hn54wy?9{xJnB4r7)STj&`1s7c%#!$cy@JYH95z6~(wtPgB37VQkkQ2; O#z$sGM#ds$APWG?rXQC8 literal 0 HcmV?d00001 diff --git a/backend/models/__pycache__/models.cpython-313.pyc b/backend/models/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a982bcc481907fa112686d9d316a4f450215086a GIT binary patch literal 9203 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 IR*D|}4Hd53(*OVf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7c8bb79f6668306c4e7c46d374265c03f9d41a93 GIT binary patch literal 36864 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 + + `; + + // 添加到页面 + 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