refactor(api): 重构数据库访问为SQLAlchemy绑定的session

- 统一移除手动创建的数据库session,统一使用models模块中的db.session
- 修正项目创建接口,增加开始和结束日期的格式验证与处理
- 更新导入项目接口,使用枚举类型校验项目类型并优化异常处理
- 更新统计接口,避免多次查询假期数据,优化日期字符串处理
- 删除回滚前多余的session关闭调用,改为使用db.session.rollback()
- app.py中重构数据库初始化:统一配置SQLAlchemy,动态创建数据库路径和表
- 项目模型新增开始日期和结束日期字段支持
- 添加导入批次历史记录模型支持
- 优化工具函数中日期类型提示,移除无用导入
- 更新requirements.txt依赖版本回退,确保兼容性
- 前端菜单添加导入历史导航入口,实现页面访问路由绑定
This commit is contained in:
2025-09-04 18:12:24 +08:00
parent ef9432f6da
commit 8938ce2708
29 changed files with 3490 additions and 150 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

118
backend/api/data_import.py Normal file
View File

@@ -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

View File

@@ -1,25 +1,17 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from sqlalchemy.orm import sessionmaker from models.models import db, Project, ProjectType
from sqlalchemy import create_engine from models.utils import *
from backend.models.models import Project, ProjectType
from backend.models.utils import *
import csv import csv
import io import io
from datetime import datetime
projects_bp = Blueprint('projects', __name__) 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']) @projects_bp.route('/api/projects', methods=['GET'])
def get_projects(): def get_projects():
"""获取所有项目列表""" """获取所有项目列表"""
try: try:
session = get_db_session() projects = db.session.query(Project).filter_by(is_active=True).all()
projects = session.query(Project).filter_by(is_active=True).all()
result = [] result = []
for project in projects: for project in projects:
@@ -32,10 +24,10 @@ def get_projects():
result.append(project_dict) result.append(project_dict)
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects', methods=['POST']) @projects_bp.route('/api/projects', methods=['POST'])
@@ -43,14 +35,19 @@ def create_project():
"""创建新项目""" """创建新项目"""
try: try:
data = request.json 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'): if not data.get('project_name') or not data.get('customer_name') or not data.get('project_type'):
return jsonify({'success': False, 'error': '项目名称、客户名和项目类型为必填项'}), 400 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'): if not data.get('project_code'):
return jsonify({'success': False, 'error': '传统项目需要填写项目代码'}), 400 return jsonify({'success': False, 'error': '传统项目需要填写项目代码'}), 400
project_code = data['project_code'] project_code = data['project_code']
@@ -60,47 +57,64 @@ def create_project():
return jsonify({'success': False, 'error': 'PSI项目需要填写合同号'}), 400 return jsonify({'success': False, 'error': 'PSI项目需要填写合同号'}), 400
project_code = 'PSI-PROJ' # PSI项目统一代码 project_code = 'PSI-PROJ' # PSI项目统一代码
contract_number = data['contract_number'] 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 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'], customer_name=data['customer_name'],
project_code=project_code, project_code=project_code,
project_type=ProjectType.TRADITIONAL project_type=ProjectType.TRADITIONAL
).first() ).first()
else: else:
# PSI项目客户名+合同号唯一 # PSI项目客户名+合同号唯一
existing_project = session.query(Project).filter_by( existing_project = db.session.query(Project).filter_by(
customer_name=data['customer_name'], customer_name=data['customer_name'],
contract_number=contract_number, contract_number=contract_number,
project_type=ProjectType.PSI project_type=ProjectType.PSI
).first() ).first()
if existing_project: if existing_project:
session.close()
return jsonify({'success': False, 'error': '项目已存在'}), 400 return jsonify({'success': False, 'error': '项目已存在'}), 400
# 创建新项目 # 创建新项目
project = Project( project = Project(
project_name=data['project_name'], project_name=data['project_name'],
project_type=ProjectType(data['project_type']), project_type=project_type,
project_code=project_code, project_code=project_code,
customer_name=data['customer_name'], customer_name=data['customer_name'],
contract_number=contract_number, contract_number=contract_number,
description=data.get('description', '') description=data.get('description', ''),
start_date=start_date,
end_date=end_date
) )
session.add(project) db.session.add(project)
session.commit() db.session.commit()
result = project.to_dict() result = project.to_dict()
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects/<int:project_id>', methods=['PUT']) @projects_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
@@ -108,11 +122,9 @@ def update_project(project_id):
"""更新项目信息""" """更新项目信息"""
try: try:
data = request.json 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: if not project:
session.close()
return jsonify({'success': False, 'error': '项目不存在'}), 404 return jsonify({'success': False, 'error': '项目不存在'}), 404
# 更新字段 # 更新字段
@@ -121,33 +133,30 @@ def update_project(project_id):
if 'description' in data: if 'description' in data:
project.description = data['description'] project.description = data['description']
session.commit() db.session.commit()
result = project.to_dict() result = project.to_dict()
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects/<int:project_id>', methods=['DELETE']) @projects_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
def delete_project(project_id): def delete_project(project_id):
"""删除项目(软删除)""" """删除项目(软删除)"""
try: try:
session = get_db_session() project = db.session.query(Project).get(project_id)
project = session.query(Project).get(project_id)
if not project: if not project:
session.close()
return jsonify({'success': False, 'error': '项目不存在'}), 404 return jsonify({'success': False, 'error': '项目不存在'}), 404
project.is_active = False project.is_active = False
session.commit() db.session.commit()
session.close()
return jsonify({'success': True, 'message': '项目已删除'}) return jsonify({'success': True, 'message': '项目已删除'})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@projects_bp.route('/api/projects/import', methods=['POST']) @projects_bp.route('/api/projects/import', methods=['POST'])
@@ -161,8 +170,6 @@ def import_projects():
if file.filename == '' or not file.filename.endswith('.csv'): if file.filename == '' or not file.filename.endswith('.csv'):
return jsonify({'success': False, 'error': '请选择有效的CSV文件'}), 400 return jsonify({'success': False, 'error': '请选择有效的CSV文件'}), 400
session = get_db_session()
# 读取CSV文件 # 读取CSV文件
stream = io.StringIO(file.stream.read().decode("utf-8")) stream = io.StringIO(file.stream.read().decode("utf-8"))
csv_reader = csv.DictReader(stream) csv_reader = csv.DictReader(stream)
@@ -177,13 +184,15 @@ def import_projects():
errors.append(f"{row_num}行:项目名称、客户名和项目类型为必填项") errors.append(f"{row_num}行:项目名称、客户名和项目类型为必填项")
continue continue
project_type = row['项目类型'].lower() project_type_str = row['项目类型'].lower()
if project_type not in ['traditional', 'psi']: try:
project_type = ProjectType(project_type_str)
except ValueError:
errors.append(f"{row_num}行:项目类型只能是 traditional 或 psi") errors.append(f"{row_num}行:项目类型只能是 traditional 或 psi")
continue continue
# 根据项目类型设置字段 # 根据项目类型设置字段
if project_type == 'traditional': if project_type == ProjectType.TRADITIONAL:
if not row.get('项目代码'): if not row.get('项目代码'):
errors.append(f"{row_num}行:传统项目需要填写项目代码") errors.append(f"{row_num}行:传统项目需要填写项目代码")
continue continue
@@ -198,14 +207,14 @@ def import_projects():
# 检查重复 # 检查重复
existing_project = None existing_project = None
if 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=row['客户名'], customer_name=row['客户名'],
project_code=project_code, project_code=project_code,
project_type=ProjectType.TRADITIONAL project_type=ProjectType.TRADITIONAL
).first() ).first()
else: else:
existing_project = session.query(Project).filter_by( existing_project = db.session.query(Project).filter_by(
customer_name=row['客户名'], customer_name=row['客户名'],
contract_number=contract_number, contract_number=contract_number,
project_type=ProjectType.PSI project_type=ProjectType.PSI
@@ -218,21 +227,20 @@ def import_projects():
# 创建项目 # 创建项目
project = Project( project = Project(
project_name=row['项目名称'], project_name=row['项目名称'],
project_type=ProjectType(project_type), project_type=project_type,
project_code=project_code, project_code=project_code,
customer_name=row['客户名'], customer_name=row['客户名'],
contract_number=contract_number, contract_number=contract_number,
description=row.get('描述', '') description=row.get('描述', '')
) )
session.add(project) db.session.add(project)
created_count += 1 created_count += 1
except Exception as e: except Exception as e:
errors.append(f"{row_num}行:{str(e)}") errors.append(f"{row_num}行:{str(e)}")
session.commit() db.session.commit()
session.close()
return jsonify({ return jsonify({
'success': True, 'success': True,
@@ -242,4 +250,5 @@ def import_projects():
}) })
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1,35 +1,25 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from sqlalchemy.orm import sessionmaker from sqlalchemy import and_
from sqlalchemy import create_engine, and_ from models.models import db, TimeRecord, Project, CutoffPeriod, Holiday
from backend.models.models import TimeRecord, Project, CutoffPeriod from models.utils import *
from backend.models.utils import *
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from collections import defaultdict from collections import defaultdict
statistics_bp = Blueprint('statistics', __name__) 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']) @statistics_bp.route('/api/statistics/weekly', methods=['GET'])
def get_weekly_statistics(): def get_weekly_statistics():
"""获取周统计数据""" """获取周统计数据"""
try: try:
session = get_db_session()
# 获取查询参数 # 获取查询参数
period_id = request.args.get('period_id') period_id = request.args.get('period_id')
start_date = request.args.get('start_date') start_date_str = request.args.get('start_date')
end_date = request.args.get('end_date') end_date_str = request.args.get('end_date')
# 如果指定了周期ID使用周期的日期范围 # 如果指定了周期ID使用周期的日期范围
if period_id: if period_id:
period = session.query(CutoffPeriod).get(int(period_id)) period = db.session.query(CutoffPeriod).get(int(period_id))
if not period: if not period:
session.close()
return jsonify({'success': False, 'error': '周期不存在'}), 404 return jsonify({'success': False, 'error': '周期不存在'}), 404
start_date = period.start_date start_date = period.start_date
@@ -38,12 +28,11 @@ def get_weekly_statistics():
period_info = period.to_dict() period_info = period.to_dict()
else: else:
# 使用指定的日期范围 # 使用指定的日期范围
if not start_date or not end_date: if not start_date_str or not end_date_str:
session.close()
return jsonify({'success': False, 'error': '请提供开始日期和结束日期'}), 400 return jsonify({'success': False, 'error': '请提供开始日期和结束日期'}), 400
start_date = datetime.strptime(start_date, '%Y-%m-%d').date() start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date, '%Y-%m-%d').date() end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
target_hours = 40 # 默认目标工时 target_hours = 40 # 默认目标工时
period_info = { period_info = {
'period_name': f"{start_date.strftime('%m月%d')}-{end_date.strftime('%m月%d')}", '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) and_(TimeRecord.date >= start_date, TimeRecord.date <= end_date)
).order_by(TimeRecord.date).all() ).order_by(TimeRecord.date).all()
@@ -68,6 +57,9 @@ def get_weekly_statistics():
record_dict[record.date] = [] record_dict[record.date] = []
record_dict[record.date].append(record) record_dict[record.date].append(record)
# 获取时间范围内的所有假期定义,避免在循环中重复查询
holidays_in_range = db.session.query(Holiday).all()
# 生成每日汇总 # 生成每日汇总
while current_date <= end_date: while current_date <= end_date:
day_records = record_dict.get(current_date, []) day_records = record_dict.get(current_date, [])
@@ -104,8 +96,7 @@ def get_weekly_statistics():
} }
else: else:
# 如果没有记录,生成默认记录 # 如果没有记录,生成默认记录
holidays = session.query(Holiday).all() holiday_info = is_holiday(current_date, holidays_in_range)
holiday_info = is_holiday(current_date, holidays)
daily_record = { daily_record = {
'date': current_date.isoformat(), '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 '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}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
@@ -193,14 +183,9 @@ def get_weekly_statistics():
def get_cutoff_periods(): def get_cutoff_periods():
"""获取Cut-Off周期列表""" """获取Cut-Off周期列表"""
try: try:
session = get_db_session() periods = db.session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all()
periods = session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all()
result = [period.to_dict() for period in periods] result = [period.to_dict() for period in periods]
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@@ -209,7 +194,6 @@ def create_cutoff_period():
"""创建Cut-Off周期""" """创建Cut-Off周期"""
try: try:
data = request.json data = request.json
session = get_db_session()
# 验证必填字段 # 验证必填字段
if not all(key in data for key in ['period_name', 'start_date', 'end_date']): 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 month=start_date.month
) )
session.add(period) db.session.add(period)
session.commit() db.session.commit()
result = period.to_dict() result = period.to_dict()
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@statistics_bp.route('/api/statistics/periods/<int:period_id>', methods=['DELETE']) @statistics_bp.route('/api/statistics/periods/<int:period_id>', methods=['DELETE'])
def delete_cutoff_period(period_id): def delete_cutoff_period(period_id):
"""删除Cut-Off周期""" """删除Cut-Off周期"""
try: try:
session = get_db_session() period = db.session.query(CutoffPeriod).get(period_id)
period = session.query(CutoffPeriod).get(period_id)
if not period: if not period:
session.close()
return jsonify({'success': False, 'error': '周期不存在'}), 404 return jsonify({'success': False, 'error': '周期不存在'}), 404
session.delete(period) db.session.delete(period)
session.commit() db.session.commit()
session.close()
return jsonify({'success': True, 'message': '周期已删除'}) return jsonify({'success': True, 'message': '周期已删除'})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500

View File

@@ -1,31 +1,22 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from sqlalchemy.orm import sessionmaker from sqlalchemy import and_
from sqlalchemy import create_engine, and_ from models.models import db, TimeRecord, Project, Holiday
from backend.models.models import TimeRecord, Project, Holiday from models.utils import *
from backend.models.utils import *
from datetime import datetime, date from datetime import datetime, date
import json import json
timerecords_bp = Blueprint('timerecords', __name__) 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']) @timerecords_bp.route('/api/timerecords', methods=['GET'])
def get_timerecords(): def get_timerecords():
"""获取工时记录列表""" """获取工时记录列表"""
try: try:
session = get_db_session()
# 获取查询参数 # 获取查询参数
start_date = request.args.get('start_date') start_date = request.args.get('start_date')
end_date = request.args.get('end_date') end_date = request.args.get('end_date')
project_id = request.args.get('project_id') project_id = request.args.get('project_id')
query = session.query(TimeRecord) query = db.session.query(TimeRecord).options(db.joinedload(TimeRecord.project))
# 应用筛选条件 # 应用筛选条件
if start_date: if start_date:
@@ -45,7 +36,6 @@ def get_timerecords():
record_dict['day_of_week'] = get_day_of_week_chinese(record.date) record_dict['day_of_week'] = get_day_of_week_chinese(record.date)
result.append(record_dict) result.append(record_dict)
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
@@ -56,7 +46,6 @@ def create_timerecord():
"""创建工时记录""" """创建工时记录"""
try: try:
data = request.json data = request.json
session = get_db_session()
# 验证必填字段 # 验证必填字段
if not data.get('date'): if not data.get('date'):
@@ -65,7 +54,7 @@ def create_timerecord():
record_date = datetime.strptime(data['date'], '%Y-%m-%d').date() 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) holiday_info = is_holiday(record_date, holidays)
# 计算工时 # 计算工时
@@ -93,18 +82,17 @@ def create_timerecord():
week_info=week_info week_info=week_info
) )
session.add(record) db.session.add(record)
session.commit() db.session.commit()
result = record.to_dict() result = record.to_dict()
if record.date: if record.date:
result['day_of_week'] = get_day_of_week_chinese(record.date) result['day_of_week'] = get_day_of_week_chinese(record.date)
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['PUT']) @timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['PUT'])
@@ -112,11 +100,9 @@ def update_timerecord(record_id):
"""更新工时记录""" """更新工时记录"""
try: try:
data = request.json 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: if not record:
session.close()
return jsonify({'success': False, 'error': '记录不存在'}), 404 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'] record.is_working_on_holiday = record.is_holiday and record.hours not in ['-', '0:00']
session.commit() db.session.commit()
result = record.to_dict() result = record.to_dict()
if record.date: if record.date:
result['day_of_week'] = get_day_of_week_chinese(record.date) result['day_of_week'] = get_day_of_week_chinese(record.date)
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['DELETE']) @timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['DELETE'])
def delete_timerecord(record_id): def delete_timerecord(record_id):
"""删除工时记录""" """删除工时记录"""
try: try:
session = get_db_session() record = db.session.query(TimeRecord).get(record_id)
record = session.query(TimeRecord).get(record_id)
if not record: if not record:
session.close()
return jsonify({'success': False, 'error': '记录不存在'}), 404 return jsonify({'success': False, 'error': '记录不存在'}), 404
session.delete(record) db.session.delete(record)
session.commit() db.session.commit()
session.close()
return jsonify({'success': True, 'message': '记录已删除'}) return jsonify({'success': True, 'message': '记录已删除'})
except Exception as e: except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@timerecords_bp.route('/api/timerecords/check_holiday/<string:date_str>', methods=['GET']) @timerecords_bp.route('/api/timerecords/check_holiday/<string:date_str>', methods=['GET'])
def check_holiday(date_str): def check_holiday(date_str):
"""检查指定日期是否为休息日""" """检查指定日期是否为休息日"""
try: try:
session = get_db_session()
check_date = datetime.strptime(date_str, '%Y-%m-%d').date() 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) holiday_info = is_holiday(check_date, holidays)
result = { result = {
@@ -196,7 +176,6 @@ def check_holiday(date_str):
'week_info': get_week_info(check_date) 'week_info': get_week_info(check_date)
} }
session.close()
return jsonify({'success': True, 'data': result}) return jsonify({'success': True, 'data': result})
except Exception as e: except Exception as e:

View File

@@ -1,10 +1,10 @@
from flask import Flask, render_template from flask import Flask, render_template
from flask_cors import CORS from flask_cors import CORS
from sqlalchemy import create_engine from models.models import db
from backend.models.models import Base from api.projects import projects_bp
from backend.api.projects import projects_bp from api.timerecords import timerecords_bp
from backend.api.timerecords import timerecords_bp from api.statistics import statistics_bp
from backend.api.statistics import statistics_bp from api.data_import import data_import_bp
import os import os
def create_app(): def create_app():
@@ -15,17 +15,27 @@ def create_app():
# 启用CORS支持 # 启用CORS支持
CORS(app) 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')
# 创建数据库表 app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
engine = create_engine('sqlite:///data/timetrack.db') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
Base.metadata.create_all(engine)
# 初始化数据库
db.init_app(app)
# 在应用上下文中创建数据库表
with app.app_context():
db.create_all()
# 注册蓝图 # 注册蓝图
app.register_blueprint(projects_bp) app.register_blueprint(projects_bp)
app.register_blueprint(timerecords_bp) app.register_blueprint(timerecords_bp)
app.register_blueprint(statistics_bp) app.register_blueprint(statistics_bp)
app.register_blueprint(data_import_bp)
# 主页路由 # 主页路由
@app.route('/') @app.route('/')
@@ -44,6 +54,10 @@ def create_app():
def statistics(): def statistics():
return render_template('statistics.html') return render_template('statistics.html')
@app.route('/import')
def import_page():
return render_template('import.html')
return app return app
if __name__ == '__main__': if __name__ == '__main__':

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,16 +1,16 @@
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Date, Time, ForeignKey, Enum 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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
Base = declarative_base() db = SQLAlchemy()
class ProjectType(enum.Enum): class ProjectType(enum.Enum):
TRADITIONAL = "traditional" # 传统项目 TRADITIONAL = "traditional" # 传统项目
PSI = "psi" # PSI项目 PSI = "psi" # PSI项目
class Project(Base): class Project(db.Model):
"""项目表模型""" """项目表模型"""
__tablename__ = 'projects' __tablename__ = 'projects'
@@ -26,6 +26,8 @@ class Project(Base):
contract_number = Column(String(100)) # 合同号PSI项目必填 contract_number = Column(String(100)) # 合同号PSI项目必填
description = Column(Text) description = Column(Text)
start_date = Column(Date, nullable=True) # 项目开始时间
end_date = Column(Date, nullable=True) # 项目结束时间
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -42,12 +44,14 @@ class Project(Base):
'customer_name': self.customer_name, 'customer_name': self.customer_name,
'contract_number': self.contract_number, 'contract_number': self.contract_number,
'description': self.description, '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, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_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' __tablename__ = 'time_records'
@@ -88,7 +92,7 @@ class TimeRecord(Base):
'updated_at': self.updated_at.isoformat() if self.updated_at else None 'updated_at': self.updated_at.isoformat() if self.updated_at else None
} }
class Holiday(Base): class Holiday(db.Model):
"""休息日配置表模型""" """休息日配置表模型"""
__tablename__ = 'holidays' __tablename__ = 'holidays'
@@ -109,7 +113,7 @@ class Holiday(Base):
'created_at': self.created_at.isoformat() if self.created_at else None 'created_at': self.created_at.isoformat() if self.created_at else None
} }
class CutoffPeriod(Base): class CutoffPeriod(db.Model):
"""Cut-Off周期表模型""" """Cut-Off周期表模型"""
__tablename__ = 'cutoff_periods' __tablename__ = 'cutoff_periods'
@@ -134,4 +138,29 @@ class CutoffPeriod(Base):
'year': self.year, 'year': self.year,
'month': self.month, 'month': self.month,
'created_at': self.created_at.isoformat() if self.created_at else None 'created_at': self.created_at.isoformat() if self.created_at else None
} }
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
}

View File

@@ -1,12 +1,11 @@
import datetime import datetime
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from backend.models.models import Holiday
def is_weekend(date: datetime.date) -> bool: def is_weekend(date: datetime.date) -> bool:
"""判断是否为周末""" """判断是否为周末"""
return date.weekday() >= 5 # 周六=5, 周日=6 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=周日 day_of_week = date.weekday() # 0=周一, 6=周日
is_weekend_day = day_of_week >= 5 # 周六、周日 is_weekend_day = day_of_week >= 5 # 周六、周日

BIN
data/timetrack.db Normal file

Binary file not shown.

View File

@@ -1,7 +1,5 @@
Flask==2.3.3 Flask==2.3.3
Flask-CORS==4.0.0 Flask-CORS==4.0.0
SQLAlchemy==2.0.23 SQLAlchemy==1.4.54
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.0.5
python-dateutil==2.8.2 python-dateutil==2.8.2
openpyxl==3.1.2
pandas==2.1.4

580
static/css/styles.css Normal file
View File

@@ -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;
}

373
static/js/common.js Normal file
View File

@@ -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 = `
<span>${message}</span>
<button onclick="this.parentElement.remove()">&times;</button>
`;
// 添加到页面
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);
}

210
static/js/dashboard.js Normal file
View File

@@ -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 = '<p class="text-center">加载失败</p>';
}
}
}
// 渲染最近记录
function renderRecentRecords(records) {
const container = document.getElementById('recent-records-list');
if (!container) return;
if (records.length === 0) {
container.innerHTML = '<p class="text-center">暂无最近记录</p>';
return;
}
container.innerHTML = `
<div class="recent-records-table">
<table class="data-table">
<thead>
<tr>
<th>日期</th>
<th>事件</th>
<th>项目</th>
<th>工时</th>
</tr>
</thead>
<tbody>
${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 `
<tr class="${rowClass}">
<td>
${formatDate(record.date)}
${isToday(record.date) ? '<span class="today-badge">今天</span>' : ''}
</td>
<td>${escapeHtml(record.event_description || '-')}</td>
<td>${escapeHtml(projectDisplay)}</td>
<td>${record.hours || '-'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
// 获取行的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);
}

386
static/js/projects.js Normal file
View File

@@ -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 = '<tr><td colspan="8" class="text-center">暂无项目数据</td></tr>';
return;
}
tbody.innerHTML = projects.map(project => `
<tr>
<td>${escapeHtml(project.project_name)}</td>
<td>
<span class="badge ${project.project_type === 'traditional' ? 'badge-primary' : 'badge-secondary'}">
${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'}
</span>
</td>
<td>${escapeHtml(project.customer_name)}</td>
<td>
${project.project_type === 'traditional'
? escapeHtml(project.project_code)
: escapeHtml(project.contract_number || 'PSI-PROJ')}
</td>
<td>
<span class="badge ${project.is_active ? 'badge-success' : 'badge-danger'}">
${project.is_active ? '活跃' : '禁用'}
</span>
</td>
<td>${formatDate(project.start_date)}</td>
<td>${formatDateTime(project.created_at)}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editProject(${project.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">删除</button>
</td>
</tr>
`).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 = '<tr><td colspan="8" class="text-center">没有符合条件的项目</td></tr>';
return;
}
tbody.innerHTML = filteredProjects.map(project => `
<tr>
<td>${escapeHtml(project.project_name)}</td>
<td>
<span class="badge ${project.project_type === 'traditional' ? 'badge-primary' : 'badge-secondary'}">
${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'}
</span>
</td>
<td>${escapeHtml(project.customer_name)}</td>
<td>
${project.project_type === 'traditional'
? escapeHtml(project.project_code)
: escapeHtml(project.contract_number || 'PSI-PROJ')}
</td>
<td>
<span class="badge ${project.is_active ? 'badge-success' : 'badge-danger'}">
${project.is_active ? '活跃' : '禁用'}
</span>
</td>
<td>${formatDate(project.start_date)}</td>
<td>${formatDateTime(project.created_at)}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editProject(${project.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">删除</button>
</td>
</tr>
`).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 = `<div class="import-summary">
<h4>导入完成</h4>
<p>成功导入 <strong>${result.created_count}</strong> 个项目</p>
</div>`;
if (result.errors && result.errors.length > 0) {
html += `<div class="import-errors">
<h5>导入错误 (${result.errors.length} 项):</h5>
<ul>`;
result.errors.forEach(error => {
html += `<li>${escapeHtml(error)}</li>`;
});
html += `</ul></div>`;
}
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);
}

485
static/js/statistics.js Normal file
View File

@@ -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 `
<tr class="${rowClass}">
<td>${formatDate(record.date)}</td>
<td>${record.day_of_week}</td>
<td>${escapeHtml(record.event)}</td>
<td>${escapeHtml(record.project)}</td>
<td>${record.start_time}</td>
<td>${record.end_time}</td>
<td>${escapeHtml(record.activity_num)}</td>
<td>${record.hours}</td>
</tr>
`;
}).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 = '<tr><td colspan="3" style="text-align: center;">暂无项目工时数据</td></tr>';
// 清理画布
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 = `
<tr>
<td>${escapeHtml(item.project)}</td>
<td>${item.hours}</td>
<td>${item.percentage}%</td>
</tr>
`;
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 = '<tr><td colspan="6" class="text-center">暂无周期数据</td></tr>';
return;
}
tbody.innerHTML = cutoffPeriods.map(period => `
<tr>
<td>${escapeHtml(period.period_name)}</td>
<td>${formatDate(period.start_date)}</td>
<td>${formatDate(period.end_date)}</td>
<td>${period.weeks}</td>
<td>${period.target_hours}小时</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deletePeriod(${period.id})">删除</button>
</td>
</tr>
`).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;
}

347
static/js/timerecords.js Normal file
View File

@@ -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 = '<tr><td colspan="9" class="text-center">暂无工时记录</td></tr>';
return;
}
tbody.innerHTML = timeRecords.map(record => {
const projectDisplay = record.project ? record.project.project_name : '-';
const rowClass = getRowClass(record);
return `
<tr class="${rowClass}">
<td>${formatDate(record.date)}</td>
<td>${record.day_of_week || getDayOfWeekChinese(record.date)}</td>
<td>${escapeHtml(record.event_description || '-')}</td>
<td>${escapeHtml(projectDisplay)}</td>
<td>${record.start_time || '-'}</td>
<td>${record.end_time || '-'}</td>
<td>${escapeHtml(record.activity_num || '-')}</td>
<td>${record.hours || '-'}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editRecord(${record.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteRecord(${record.id})">删除</button>
</td>
</tr>
`;
}).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;
}

207
templates/import.html Normal file
View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导入历史记录 - 个人工时记录系统</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<h1>个人工时记录系统</h1>
</div>
<ul class="nav-menu">
<li><a href="/" class="nav-link">首页</a></li>
<li><a href="/projects" class="nav-link">项目管理</a></li>
<li><a href="/timerecords" class="nav-link">工时记录</a></li>
<li><a href="/statistics" class="nav-link">统计分析</a></li>
<li><a href="/import" class="nav-link active">导入历史</a></li>
</ul>
</div>
</nav>
<main class="main-content">
<div class="container">
<div class="page-header">
<h2>导入历史工时记录</h2>
</div>
<!-- 导入工具 -->
<div class="card" id="import-tool-card">
<div class="card-header">
<h3><a href="#" onclick="toggleCardBody(this); return false;" style="text-decoration: none; color: inherit;">手动导入数据 &#9662;</a></h3>
</div>
<div class="card-body" style="display: none;">
<p>请在下面的文本框中粘贴您的历史工时记录,每行一条。格式为:<code>月日 项目名 开始时间 结束时间 ActivityNum</code></p>
<p>例如:<code>8月20日 长鑫CODE/02C-FBV 9:00 17:00 9296892</code></p>
<form id="import-form">
<div class="form-group">
<label for="records-input">工时记录:</label>
<textarea id="records-input" name="records" class="form-control" rows="10" placeholder="请在此处粘贴记录..."></textarea>
</div>
<button type="submit" class="btn btn-primary">开始导入</button>
</form>
<div id="import-results" class="import-results-container" style="display: none; margin-top: 1.5rem;">
<h4>导入结果</h4>
<p>成功导入 <strong id="success-count">0</strong> 条记录。</p>
<div id="failed-records-section" style="display: none;">
<p>以下 <strong id="failure-count">0</strong> 条记录导入失败:</p>
<ul id="failed-records-list" class="failures-list"></ul>
</div>
</div>
</div>
</div>
<!-- 导入历史记录 -->
<div class="history-section" style="margin-top: 2rem;">
<h3>历史记录</h3>
<div id="import-history-list" class="import-history-grid">
<!-- 历史记录卡片将由JS动态加载 -->
<p>正在加载历史记录...</p>
</div>
</div>
</div>
</main>
<script>
// 切换卡片可见性
function toggleCardBody(element) {
const cardBody = element.closest('.card').querySelector('.card-body');
const isVisible = cardBody.style.display !== 'none';
cardBody.style.display = isVisible ? 'none' : 'block';
element.innerHTML = isVisible ? '手动导入数据 &#9662;' : '手动导入数据 &#9652;';
}
// 格式化日期
function formatImportDate(isoString) {
const date = new Date(isoString);
return date.toLocaleString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
// 加载导入历史
async function loadImportHistory() {
const listContainer = document.getElementById('import-history-list');
try {
const response = await fetch('/api/import/history');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const history = await response.json();
if (history.length === 0) {
listContainer.innerHTML = '<p>暂无导入历史记录。</p>';
return;
}
listContainer.innerHTML = ''; // 清空加载提示
history.forEach(batch => {
let statusClass = '';
switch (batch.status) {
case '成功': statusClass = 'status-success'; break;
case '部分成功': statusClass = 'status-partial'; break;
case '失败': statusClass = 'status-fail'; break;
}
const card = document.createElement('div');
card.className = 'import-card';
card.innerHTML = `
<div class="import-card-header">
<h4>批次 #${batch.id}</h4>
<span class="import-date">${formatImportDate(batch.import_date)}</span>
</div>
<div class="import-card-body">
<div class="import-card-stats">
<div class="stat-item">
<span class="stat-value" style="color: var(--success-color);">${batch.success_count}</span>
<span class="stat-label">成功</span>
</div>
<div class="stat-item">
<span class="stat-value" style="color: var(--danger-color);">${batch.failure_count}</span>
<span class="stat-label">失败</span>
</div>
<div class="stat-item">
<span class="stat-value">${batch.total_records}</span>
<span class="stat-label">总计</span>
</div>
</div>
<p><strong>源数据预览:</strong></p>
<div class="source-preview">${batch.source_preview || '无预览'}</div>
</div>
<div class="import-card-footer">
<span class="status-badge ${statusClass}">${batch.status}</span>
</div>
`;
listContainer.appendChild(card);
});
} catch (error) {
console.error('加载导入历史失败:', error);
listContainer.innerHTML = '<p style="color: var(--danger-color);">无法加载导入历史记录,请稍后重试。</p>';
}
}
// 处理导入表单提交
document.getElementById('import-form').addEventListener('submit', async function(event) {
event.preventDefault();
const recordsText = document.getElementById('records-input').value;
if (!recordsText.trim()) {
alert('请输入要导入的记录。');
return;
}
try {
const response = await fetch('/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ records: recordsText })
});
const result = await response.json();
const resultsContainer = document.getElementById('import-results');
const successCount = document.getElementById('success-count');
const failedSection = document.getElementById('failed-records-section');
const failureCount = document.getElementById('failure-count');
const failedList = document.getElementById('failed-records-list');
successCount.textContent = result.success_count;
failedList.innerHTML = '';
if (result.failures && result.failures.length > 0) {
failureCount.textContent = result.failure_count;
result.failures.forEach(fail => {
const li = document.createElement('li');
li.textContent = `[${fail.reason}] ${fail.line}`;
failedList.appendChild(li);
});
failedSection.style.display = 'block';
} else {
failedSection.style.display = 'none';
}
resultsContainer.style.display = 'block';
// 导入成功后清空输入框并重新加载历史
if (result.success_count > 0) {
document.getElementById('records-input').value = '';
loadImportHistory();
}
} catch (error) {
console.error('导入请求失败:', error);
alert('导入请求失败,请检查网络连接或联系管理员。');
}
});
// 页面加载时执行
document.addEventListener('DOMContentLoaded', loadImportHistory);
</script>
</body>
</html>

View File

@@ -17,6 +17,7 @@
<li><a href="/projects" class="nav-link">项目管理</a></li> <li><a href="/projects" class="nav-link">项目管理</a></li>
<li><a href="/timerecords" class="nav-link">工时记录</a></li> <li><a href="/timerecords" class="nav-link">工时记录</a></li>
<li><a href="/statistics" class="nav-link">统计分析</a></li> <li><a href="/statistics" class="nav-link">统计分析</a></li>
<li><a href="/import" class="nav-link">导入历史</a></li>
</ul> </ul>
</div> </div>
</nav> </nav>

224
templates/projects.html Normal file
View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目管理 - 个人工时记录系统</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<h1>个人工时记录系统</h1>
</div>
<ul class="nav-menu">
<li><a href="/" class="nav-link">首页</a></li>
<li><a href="/projects" class="nav-link active">项目管理</a></li>
<li><a href="/timerecords" class="nav-link">工时记录</a></li>
<li><a href="/statistics" class="nav-link">统计分析</a></li>
<li><a href="/import" class="nav-link">导入历史</a></li>
</ul>
</div>
</nav>
<main class="main-content">
<div class="container">
<div class="page-header">
<h2>项目管理</h2>
<div class="page-actions">
<button class="btn btn-primary" onclick="showCreateProjectModal()">新建项目</button>
<button class="btn btn-secondary" onclick="showImportModal()">导入项目</button>
<select id="project-type-filter" class="form-control" onchange="filterProjects()">
<option value="">全部项目类型</option>
<option value="traditional">传统项目</option>
<option value="psi">PSI项目</option>
</select>
</div>
</div>
<!-- 项目统计 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value" id="total-projects-count">0</div>
<div class="stat-label">总项目数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="traditional-projects-count">0</div>
<div class="stat-label">传统项目</div>
</div>
<div class="stat-card">
<div class="stat-value" id="psi-projects-count">0</div>
<div class="stat-label">PSI项目</div>
</div>
</div>
<!-- 项目列表表格 -->
<div class="table-container">
<table class="data-table" id="projects-table">
<thead>
<tr>
<th>项目名称</th>
<th>项目类型</th>
<th>客户名</th>
<th>标识码</th>
<th>状态</th>
<th>项目开始时间</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="projects-tbody">
<tr>
<td colspan="7" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- 新建项目模态框 -->
<div id="create-project-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">新建项目</h3>
<button class="close-btn" onclick="closeModal('create-project-modal')">&times;</button>
</div>
<form id="project-form">
<div class="modal-body">
<div class="form-group">
<label for="project_name">项目名称 *</label>
<input type="text" id="project_name" name="project_name" class="form-control" required>
</div>
<div class="form-group">
<label for="customer_name">客户名 *</label>
<input type="text" id="customer_name" name="customer_name" class="form-control"
placeholder="如NexChip" required>
</div>
<div class="form-group">
<label for="project_type">项目类型 *</label>
<select id="project_type" name="project_type" class="form-control"
onchange="toggleProjectFields()" required>
<option value="">请选择项目类型</option>
<option value="traditional">传统项目</option>
<option value="psi">PSI项目</option>
</select>
</div>
<!-- 传统项目字段 -->
<div id="traditional-fields" style="display:none;">
<div class="form-group">
<label for="project_code">项目代码 *</label>
<input type="text" id="project_code" name="project_code"
class="form-control" placeholder="如02C-FBV">
</div>
</div>
<!-- PSI项目字段 -->
<div id="psi-fields" style="display:none;">
<div class="form-group">
<label for="contract_number">合同号 *</label>
<input type="text" id="contract_number" name="contract_number"
class="form-control" placeholder="如ID00462761">
</div>
<div class="form-group">
<label>项目代码</label>
<input type="text" value="PSI-PROJ" class="form-control" readonly disabled>
<small class="form-text">PSI项目统一使用代码 PSI-PROJ</small>
</div>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea id="description" name="description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label for="start_date">项目开始时间</label>
<input type="date" id="start_date" name="start_date" class="form-control">
</div>
<div class="form-group">
<label for="end_date">项目结束时间</label>
<input type="date" id="end_date" name="end_date" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('create-project-modal')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- 导入项目模态框 -->
<div id="import-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>导入项目</h3>
<button class="close-btn" onclick="closeModal('import-modal')">&times;</button>
</div>
<div class="modal-body">
<div class="import-instructions">
<h4>CSV文件格式要求</h4>
<p>请确保CSV文件包含以下列按顺序</p>
<ul>
<li><strong>项目名称</strong> - 必填</li>
<li><strong>项目类型</strong> - 必填traditional 或 psi</li>
<li><strong>客户名</strong> - 必填</li>
<li><strong>项目代码</strong> - 传统项目必填PSI项目可忽略</li>
<li><strong>合同号</strong> - PSI项目必填传统项目可为空</li>
<li><strong>描述</strong> - 可选</li>
</ul>
</div>
<div class="form-group">
<label for="import-file">选择CSV文件</label>
<input type="file" id="import-file" accept=".csv" class="form-control">
</div>
<div class="sample-templates">
<h4>示例模板:</h4>
<div class="template-buttons">
<button type="button" class="btn btn-outline" onclick="downloadTemplate('traditional')">
下载传统项目模板
</button>
<button type="button" class="btn btn-outline" onclick="downloadTemplate('psi')">
下载PSI项目模板
</button>
<button type="button" class="btn btn-outline" onclick="downloadTemplate('mixed')">
下载混合项目模板
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">取消</button>
<button type="button" class="btn btn-primary" onclick="importProjects()">导入</button>
</div>
</div>
</div>
<!-- 导入结果模态框 -->
<div id="import-result-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>导入结果</h3>
<button class="close-btn" onclick="closeModal('import-result-modal')">&times;</button>
</div>
<div class="modal-body">
<div id="import-result-content"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="closeModal('import-result-modal')">确定</button>
</div>
</div>
</div>
<script src="/static/js/common.js"></script>
<script src="/static/js/projects.js"></script>
</body>
</html>

234
templates/statistics.html Normal file
View File

@@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>统计分析 - 个人工时记录系统</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<h1>个人工时记录系统</h1>
</div>
<ul class="nav-menu">
<li><a href="/" class="nav-link">首页</a></li>
<li><a href="/projects" class="nav-link">项目管理</a></li>
<li><a href="/timerecords" class="nav-link">工时记录</a></li>
<li><a href="/statistics" class="nav-link active">统计分析</a></li>
<li><a href="/import" class="nav-link">导入历史</a></li>
</ul>
</div>
</nav>
<main class="main-content">
<div class="container">
<div class="page-header">
<h2>统计分析</h2>
<div class="page-actions">
<button class="btn btn-primary" onclick="showCreatePeriodModal()">新建周期</button>
</div>
</div>
<!-- 周期选择和控制 -->
<div class="period-control">
<div class="form-row">
<div class="form-group">
<label for="period-select">Cut-Off周期</label>
<select id="period-select" class="form-control" onchange="loadWeeklyStats()">
<option value="">请选择周期</option>
</select>
</div>
<div class="form-group">
<label>或者自定义日期范围</label>
<div class="date-range">
<input type="date" id="custom-start-date" class="form-control" placeholder="开始日期">
<span>-</span>
<input type="date" id="custom-end-date" class="form-control" placeholder="结束日期">
<button class="btn btn-secondary" onclick="loadCustomStats()">查看统计</button>
</div>
</div>
</div>
</div>
<!-- 统计概览 -->
<div id="stats-overview" class="stats-section" style="display:none;">
<div class="period-info">
<h3 id="current-period-name">周期名称</h3>
<p id="period-date-range">日期范围</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="workday-total">0:00</div>
<div class="stat-label">工作日总工时</div>
</div>
<div class="stat-card">
<div class="stat-value" id="holiday-total">0:00</div>
<div class="stat-label">休息日工时</div>
</div>
<div class="stat-card">
<div class="stat-value" id="weekly-total">0:00</div>
<div class="stat-label">总工时</div>
</div>
<div class="stat-card">
<div class="stat-value" id="completion-rate">0%</div>
<div class="stat-label">目标完成度</div>
</div>
</div>
<div class="working-days-info">
<div class="day-stats">
<span class="day-stat-item"><span class="day-stat-label">工作天数:</span><strong id="working-days">0</strong></span>
<span class="day-stat-item"><span class="day-stat-label">休息日工作:</span><strong id="holiday-work-days">0</strong></span>
<span class="day-stat-item"><span class="day-stat-label">休息天数:</span><strong id="rest-days">0</strong></span>
</div>
</div>
</div>
<!-- 每日工时详情表格 -->
<div id="daily-details" class="table-section" style="display:none;">
<h3>每日工时详情</h3>
<div class="table-container">
<table class="data-table" id="daily-stats-table">
<thead>
<tr>
<th>日期</th>
<th>星期</th>
<th>事件</th>
<th>项目</th>
<th>开始时间</th>
<th>结束时间</th>
<th>Activity Num</th>
<th>工时</th>
</tr>
</thead>
<tbody id="daily-stats-tbody">
</tbody>
</table>
</div>
</div>
<!-- 项目工时分布 -->
<div id="project-distribution" class="chart-section" style="display:none;">
<h3>项目工时分布</h3>
<div class="distribution-layout">
<div class="chart-container">
<canvas id="project-hours-chart"></canvas>
</div>
<div class="table-container distribution-table">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>工时</th>
<th>占比</th>
</tr>
</thead>
<tbody id="project-hours-tbody">
<!-- 数据将由JS填充 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- 新建Cut-Off周期模态框 -->
<div id="create-period-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>新建Cut-Off周期</h3>
<button class="close-btn" onclick="closeModal('create-period-modal')">&times;</button>
</div>
<form id="period-form">
<div class="modal-body">
<div class="form-group">
<label for="period_name">周期名称 *</label>
<input type="text" id="period_name" name="period_name" class="form-control"
placeholder="如2024年12月-2025年1月" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="start_date">开始日期 *</label>
<input type="date" id="start_date" name="start_date"
class="form-control" onchange="calculatePeriodInfo()" required>
</div>
<div class="form-group">
<label for="end_date">结束日期 *</label>
<input type="date" id="end_date" name="end_date"
class="form-control" onchange="calculatePeriodInfo()" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="weeks">周数</label>
<input type="number" id="weeks" name="weeks" class="form-control"
min="1" readonly>
</div>
<div class="form-group">
<label for="target_hours">目标工时</label>
<input type="number" id="target_hours" name="target_hours"
class="form-control" min="1" step="1">
</div>
</div>
<div class="period-templates">
<h4>快速模板:</h4>
<div class="template-buttons">
<button type="button" class="btn btn-outline" onclick="applyTemplate('four-weeks')">
4周周期 (160小时)
</button>
<button type="button" class="btn btn-outline" onclick="applyTemplate('five-weeks')">
5周周期 (200小时)
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('create-period-modal')">取消</button>
<button type="submit" class="btn btn-primary">创建</button>
</div>
</form>
</div>
</div>
<!-- 周期管理模态框 -->
<div id="manage-periods-modal" class="modal">
<div class="modal-content large">
<div class="modal-header">
<h3>Cut-Off周期管理</h3>
<button class="close-btn" onclick="closeModal('manage-periods-modal')">&times;</button>
</div>
<div class="modal-body">
<div class="table-container">
<table class="data-table" id="periods-table">
<thead>
<tr>
<th>周期名称</th>
<th>开始日期</th>
<th>结束日期</th>
<th>周数</th>
<th>目标工时</th>
<th>操作</th>
</tr>
</thead>
<tbody id="periods-tbody">
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="closeModal('manage-periods-modal')">关闭</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/static/js/common.js"></script>
<script src="/static/js/statistics.js"></script>
</body>

167
templates/timerecords.html Normal file
View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工时记录 - 个人工时记录系统</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<h1>个人工时记录系统</h1>
</div>
<ul class="nav-menu">
<li><a href="/" class="nav-link">首页</a></li>
<li><a href="/projects" class="nav-link">项目管理</a></li>
<li><a href="/timerecords" class="nav-link active">工时记录</a></li>
<li><a href="/statistics" class="nav-link">统计分析</a></li>
<li><a href="/import" class="nav-link">导入历史</a></li>
</ul>
</div>
</nav>
<main class="main-content">
<div class="container">
<div class="page-header">
<h2>工时记录</h2>
<div class="page-actions">
<button class="btn btn-primary" onclick="showCreateRecordModal()">新建记录</button>
</div>
</div>
<!-- 筛选条件 -->
<div class="filter-section">
<div class="filter-row">
<div class="form-group">
<label for="filter-start-date">开始日期</label>
<input type="date" id="filter-start-date" class="form-control" onchange="loadTimeRecords()">
</div>
<div class="form-group">
<label for="filter-end-date">结束日期</label>
<input type="date" id="filter-end-date" class="form-control" onchange="loadTimeRecords()">
</div>
<div class="form-group">
<label for="filter-project">项目</label>
<select id="filter-project" class="form-control" onchange="loadTimeRecords()">
<option value="">全部项目</option>
</select>
</div>
<div class="form-group">
<button class="btn btn-secondary" onclick="resetFilters()">重置筛选</button>
</div>
</div>
</div>
<!-- 工时记录表格 -->
<div class="table-container">
<table class="data-table" id="timerecords-table">
<thead>
<tr>
<th>日期</th>
<th>星期</th>
<th>事件</th>
<th>项目</th>
<th>开始时间</th>
<th>结束时间</th>
<th>Activity Num</th>
<th>工时</th>
<th>操作</th>
</tr>
</thead>
<tbody id="timerecords-tbody">
<tr>
<td colspan="9" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<!-- 新建/编辑工时记录模态框 -->
<div id="timerecord-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="timerecord-modal-title">新建工时记录</h3>
<button class="close-btn" onclick="closeModal('timerecord-modal')">&times;</button>
</div>
<form id="timerecord-form">
<div class="modal-body">
<!-- 日期和休息日信息 -->
<div class="form-row">
<div class="form-group">
<label for="record_date">日期 *</label>
<input type="date" id="record_date" name="date" class="form-control"
onchange="checkHoliday()" required>
</div>
<div class="form-group">
<div id="holiday-info" class="holiday-info" style="display:none;">
<span id="holiday-badge" class="badge"></span>
<span id="holiday-text"></span>
</div>
</div>
</div>
<!-- 事件描述 -->
<div class="form-group">
<label for="event_description">事件描述</label>
<input type="text" id="event_description" name="event_description"
class="form-control" placeholder="如浦发银行VIOS升级">
</div>
<!-- 项目选择 -->
<div class="form-group">
<label for="project_id">项目</label>
<select id="project_id" name="project_id" class="form-control">
<option value="">请选择项目</option>
</select>
</div>
<!-- 时间输入 -->
<div class="form-row">
<div class="form-group">
<label for="start_time">开始时间</label>
<input type="time" id="start_time" name="start_time"
class="form-control" onchange="updateHoursInput()">
</div>
<div class="form-group">
<label for="end_time">结束时间</label>
<input type="time" id="end_time" name="end_time"
class="form-control" onchange="updateHoursInput()">
</div>
</div>
<!-- Activity Num 和工时 -->
<div class="form-row">
<div class="form-group">
<label for="activity_num">Activity Num</label>
<input type="text" id="activity_num" name="activity_num"
class="form-control" placeholder="如5307905">
</div>
<div class="form-group">
<label for="hours">工时</label>
<input type="text" id="hours" name="hours" class="form-control"
placeholder="如2:42 或 8:00:00" readonly>
<small class="form-text">工时将根据开始和结束时间自动计算</small>
</div>
</div>
<!-- 休息日提示 -->
<div id="holiday-warning" class="alert alert-warning" style="display:none;">
<strong>注意:</strong>这是一个休息日,如需记录工时请填写开始和结束时间,或直接输入工时。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('timerecord-modal')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script src="/static/js/common.js"></script>
<script src="/static/js/timerecords.js"></script>
</body>
</html>