refactor(api): 重构数据库访问为SQLAlchemy绑定的session
- 统一移除手动创建的数据库session,统一使用models模块中的db.session - 修正项目创建接口,增加开始和结束日期的格式验证与处理 - 更新导入项目接口,使用枚举类型校验项目类型并优化异常处理 - 更新统计接口,避免多次查询假期数据,优化日期字符串处理 - 删除回滚前多余的session关闭调用,改为使用db.session.rollback() - app.py中重构数据库初始化:统一配置SQLAlchemy,动态创建数据库路径和表 - 项目模型新增开始日期和结束日期字段支持 - 添加导入批次历史记录模型支持 - 优化工具函数中日期类型提示,移除无用导入 - 更新requirements.txt依赖版本回退,确保兼容性 - 前端菜单添加导入历史导航入口,实现页面访问路由绑定
This commit is contained in:
BIN
backend/__pycache__/app.cpython-313.pyc
Normal file
BIN
backend/__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/data_import.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/data_import.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/projects.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/projects.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/statistics.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/statistics.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/timerecords.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/timerecords.cpython-313.pyc
Normal file
Binary file not shown.
118
backend/api/data_import.py
Normal file
118
backend/api/data_import.py
Normal 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
|
||||
@@ -1,25 +1,17 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models.models import Project, ProjectType
|
||||
from backend.models.utils import *
|
||||
from models.models import db, Project, ProjectType
|
||||
from models.utils import *
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
projects_bp = Blueprint('projects', __name__)
|
||||
|
||||
def get_db_session():
|
||||
"""获取数据库会话"""
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
@projects_bp.route('/api/projects', methods=['GET'])
|
||||
def get_projects():
|
||||
"""获取所有项目列表"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
projects = session.query(Project).filter_by(is_active=True).all()
|
||||
projects = db.session.query(Project).filter_by(is_active=True).all()
|
||||
|
||||
result = []
|
||||
for project in projects:
|
||||
@@ -32,10 +24,10 @@ def get_projects():
|
||||
|
||||
result.append(project_dict)
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects', methods=['POST'])
|
||||
@@ -43,14 +35,19 @@ def create_project():
|
||||
"""创建新项目"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
# 验证必填字段
|
||||
if not data.get('project_name') or not data.get('customer_name') or not data.get('project_type'):
|
||||
return jsonify({'success': False, 'error': '项目名称、客户名和项目类型为必填项'}), 400
|
||||
|
||||
project_type_str = data['project_type']
|
||||
try:
|
||||
project_type = ProjectType(project_type_str)
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': f'无效的项目类型: {project_type_str}'}), 400
|
||||
|
||||
# 根据项目类型设置字段
|
||||
if data['project_type'] == 'traditional':
|
||||
if project_type == ProjectType.TRADITIONAL:
|
||||
if not data.get('project_code'):
|
||||
return jsonify({'success': False, 'error': '传统项目需要填写项目代码'}), 400
|
||||
project_code = data['project_code']
|
||||
@@ -61,46 +58,63 @@ def create_project():
|
||||
project_code = 'PSI-PROJ' # PSI项目统一代码
|
||||
contract_number = data['contract_number']
|
||||
|
||||
# 处理结束日期
|
||||
end_date = None
|
||||
if data.get('end_date') and data.get('end_date') != '' :
|
||||
try:
|
||||
end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': '结束日期格式不正确,应为 YYYY-MM-DD'}), 400
|
||||
|
||||
# 处理开始日期
|
||||
start_date = None
|
||||
if data.get('start_date') and data.get('start_date') != '' :
|
||||
try:
|
||||
start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': '开始日期格式不正确,应为 YYYY-MM-DD'}), 400
|
||||
|
||||
# 检查唯一性约束
|
||||
existing_project = None
|
||||
if data['project_type'] == 'traditional':
|
||||
if project_type == ProjectType.TRADITIONAL:
|
||||
# 传统项目:客户名+项目代码唯一
|
||||
existing_project = session.query(Project).filter_by(
|
||||
existing_project = db.session.query(Project).filter_by(
|
||||
customer_name=data['customer_name'],
|
||||
project_code=project_code,
|
||||
project_type=ProjectType.TRADITIONAL
|
||||
).first()
|
||||
else:
|
||||
# PSI项目:客户名+合同号唯一
|
||||
existing_project = session.query(Project).filter_by(
|
||||
existing_project = db.session.query(Project).filter_by(
|
||||
customer_name=data['customer_name'],
|
||||
contract_number=contract_number,
|
||||
project_type=ProjectType.PSI
|
||||
).first()
|
||||
|
||||
if existing_project:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '项目已存在'}), 400
|
||||
|
||||
# 创建新项目
|
||||
project = Project(
|
||||
project_name=data['project_name'],
|
||||
project_type=ProjectType(data['project_type']),
|
||||
project_type=project_type,
|
||||
project_code=project_code,
|
||||
customer_name=data['customer_name'],
|
||||
contract_number=contract_number,
|
||||
description=data.get('description', '')
|
||||
description=data.get('description', ''),
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
session.add(project)
|
||||
session.commit()
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
||||
result = project.to_dict()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
|
||||
@@ -108,11 +122,9 @@ def update_project(project_id):
|
||||
"""更新项目信息"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
project = session.query(Project).get(project_id)
|
||||
project = db.session.query(Project).get(project_id)
|
||||
if not project:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '项目不存在'}), 404
|
||||
|
||||
# 更新字段
|
||||
@@ -121,33 +133,30 @@ def update_project(project_id):
|
||||
if 'description' in data:
|
||||
project.description = data['description']
|
||||
|
||||
session.commit()
|
||||
db.session.commit()
|
||||
result = project.to_dict()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
|
||||
def delete_project(project_id):
|
||||
"""删除项目(软删除)"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
project = session.query(Project).get(project_id)
|
||||
project = db.session.query(Project).get(project_id)
|
||||
if not project:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '项目不存在'}), 404
|
||||
|
||||
project.is_active = False
|
||||
session.commit()
|
||||
session.close()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '项目已删除'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects/import', methods=['POST'])
|
||||
@@ -161,8 +170,6 @@ def import_projects():
|
||||
if file.filename == '' or not file.filename.endswith('.csv'):
|
||||
return jsonify({'success': False, 'error': '请选择有效的CSV文件'}), 400
|
||||
|
||||
session = get_db_session()
|
||||
|
||||
# 读取CSV文件
|
||||
stream = io.StringIO(file.stream.read().decode("utf-8"))
|
||||
csv_reader = csv.DictReader(stream)
|
||||
@@ -177,13 +184,15 @@ def import_projects():
|
||||
errors.append(f"第{row_num}行:项目名称、客户名和项目类型为必填项")
|
||||
continue
|
||||
|
||||
project_type = row['项目类型'].lower()
|
||||
if project_type not in ['traditional', 'psi']:
|
||||
project_type_str = row['项目类型'].lower()
|
||||
try:
|
||||
project_type = ProjectType(project_type_str)
|
||||
except ValueError:
|
||||
errors.append(f"第{row_num}行:项目类型只能是 traditional 或 psi")
|
||||
continue
|
||||
|
||||
# 根据项目类型设置字段
|
||||
if project_type == 'traditional':
|
||||
if project_type == ProjectType.TRADITIONAL:
|
||||
if not row.get('项目代码'):
|
||||
errors.append(f"第{row_num}行:传统项目需要填写项目代码")
|
||||
continue
|
||||
@@ -198,14 +207,14 @@ def import_projects():
|
||||
|
||||
# 检查重复
|
||||
existing_project = None
|
||||
if project_type == 'traditional':
|
||||
existing_project = session.query(Project).filter_by(
|
||||
if project_type == ProjectType.TRADITIONAL:
|
||||
existing_project = db.session.query(Project).filter_by(
|
||||
customer_name=row['客户名'],
|
||||
project_code=project_code,
|
||||
project_type=ProjectType.TRADITIONAL
|
||||
).first()
|
||||
else:
|
||||
existing_project = session.query(Project).filter_by(
|
||||
existing_project = db.session.query(Project).filter_by(
|
||||
customer_name=row['客户名'],
|
||||
contract_number=contract_number,
|
||||
project_type=ProjectType.PSI
|
||||
@@ -218,21 +227,20 @@ def import_projects():
|
||||
# 创建项目
|
||||
project = Project(
|
||||
project_name=row['项目名称'],
|
||||
project_type=ProjectType(project_type),
|
||||
project_type=project_type,
|
||||
project_code=project_code,
|
||||
customer_name=row['客户名'],
|
||||
contract_number=contract_number,
|
||||
description=row.get('描述', '')
|
||||
)
|
||||
|
||||
session.add(project)
|
||||
db.session.add(project)
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"第{row_num}行:{str(e)}")
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -242,4 +250,5 @@ def import_projects():
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
@@ -1,35 +1,25 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine, and_
|
||||
from backend.models.models import TimeRecord, Project, CutoffPeriod
|
||||
from backend.models.utils import *
|
||||
from sqlalchemy import and_
|
||||
from models.models import db, TimeRecord, Project, CutoffPeriod, Holiday
|
||||
from models.utils import *
|
||||
from datetime import datetime, date, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
statistics_bp = Blueprint('statistics', __name__)
|
||||
|
||||
def get_db_session():
|
||||
"""获取数据库会话"""
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
@statistics_bp.route('/api/statistics/weekly', methods=['GET'])
|
||||
def get_weekly_statistics():
|
||||
"""获取周统计数据"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
# 获取查询参数
|
||||
period_id = request.args.get('period_id')
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
start_date_str = request.args.get('start_date')
|
||||
end_date_str = request.args.get('end_date')
|
||||
|
||||
# 如果指定了周期ID,使用周期的日期范围
|
||||
if period_id:
|
||||
period = session.query(CutoffPeriod).get(int(period_id))
|
||||
period = db.session.query(CutoffPeriod).get(int(period_id))
|
||||
if not period:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '周期不存在'}), 404
|
||||
|
||||
start_date = period.start_date
|
||||
@@ -38,12 +28,11 @@ def get_weekly_statistics():
|
||||
period_info = period.to_dict()
|
||||
else:
|
||||
# 使用指定的日期范围
|
||||
if not start_date or not end_date:
|
||||
session.close()
|
||||
if not start_date_str or not end_date_str:
|
||||
return jsonify({'success': False, 'error': '请提供开始日期和结束日期'}), 400
|
||||
|
||||
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
target_hours = 40 # 默认目标工时
|
||||
period_info = {
|
||||
'period_name': f"{start_date.strftime('%m月%d日')}-{end_date.strftime('%m月%d日')}",
|
||||
@@ -53,7 +42,7 @@ def get_weekly_statistics():
|
||||
}
|
||||
|
||||
# 查询该时间范围内的所有工时记录
|
||||
records = session.query(TimeRecord).filter(
|
||||
records = db.session.query(TimeRecord).filter(
|
||||
and_(TimeRecord.date >= start_date, TimeRecord.date <= end_date)
|
||||
).order_by(TimeRecord.date).all()
|
||||
|
||||
@@ -68,6 +57,9 @@ def get_weekly_statistics():
|
||||
record_dict[record.date] = []
|
||||
record_dict[record.date].append(record)
|
||||
|
||||
# 获取时间范围内的所有假期定义,避免在循环中重复查询
|
||||
holidays_in_range = db.session.query(Holiday).all()
|
||||
|
||||
# 生成每日汇总
|
||||
while current_date <= end_date:
|
||||
day_records = record_dict.get(current_date, [])
|
||||
@@ -104,8 +96,7 @@ def get_weekly_statistics():
|
||||
}
|
||||
else:
|
||||
# 如果没有记录,生成默认记录
|
||||
holidays = session.query(Holiday).all()
|
||||
holiday_info = is_holiday(current_date, holidays)
|
||||
holiday_info = is_holiday(current_date, holidays_in_range)
|
||||
|
||||
daily_record = {
|
||||
'date': current_date.isoformat(),
|
||||
@@ -183,7 +174,6 @@ def get_weekly_statistics():
|
||||
'completion_rate': round((workday_total + holiday_total) / target_hours * 100, 1) if target_hours > 0 else 0
|
||||
}
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
@@ -193,14 +183,9 @@ def get_weekly_statistics():
|
||||
def get_cutoff_periods():
|
||||
"""获取Cut-Off周期列表"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
periods = session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all()
|
||||
periods = db.session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all()
|
||||
result = [period.to_dict() for period in periods]
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@@ -209,7 +194,6 @@ def create_cutoff_period():
|
||||
"""创建Cut-Off周期"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
# 验证必填字段
|
||||
if not all(key in data for key in ['period_name', 'start_date', 'end_date']):
|
||||
@@ -235,33 +219,29 @@ def create_cutoff_period():
|
||||
month=start_date.month
|
||||
)
|
||||
|
||||
session.add(period)
|
||||
session.commit()
|
||||
db.session.add(period)
|
||||
db.session.commit()
|
||||
|
||||
result = period.to_dict()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@statistics_bp.route('/api/statistics/periods/<int:period_id>', methods=['DELETE'])
|
||||
def delete_cutoff_period(period_id):
|
||||
"""删除Cut-Off周期"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
period = session.query(CutoffPeriod).get(period_id)
|
||||
period = db.session.query(CutoffPeriod).get(period_id)
|
||||
if not period:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '周期不存在'}), 404
|
||||
|
||||
session.delete(period)
|
||||
session.commit()
|
||||
session.close()
|
||||
db.session.delete(period)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '周期已删除'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
@@ -1,31 +1,22 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine, and_
|
||||
from backend.models.models import TimeRecord, Project, Holiday
|
||||
from backend.models.utils import *
|
||||
from sqlalchemy import and_
|
||||
from models.models import db, TimeRecord, Project, Holiday
|
||||
from models.utils import *
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
|
||||
timerecords_bp = Blueprint('timerecords', __name__)
|
||||
|
||||
def get_db_session():
|
||||
"""获取数据库会话"""
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
@timerecords_bp.route('/api/timerecords', methods=['GET'])
|
||||
def get_timerecords():
|
||||
"""获取工时记录列表"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
# 获取查询参数
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
project_id = request.args.get('project_id')
|
||||
|
||||
query = session.query(TimeRecord)
|
||||
query = db.session.query(TimeRecord).options(db.joinedload(TimeRecord.project))
|
||||
|
||||
# 应用筛选条件
|
||||
if start_date:
|
||||
@@ -45,7 +36,6 @@ def get_timerecords():
|
||||
record_dict['day_of_week'] = get_day_of_week_chinese(record.date)
|
||||
result.append(record_dict)
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
@@ -56,7 +46,6 @@ def create_timerecord():
|
||||
"""创建工时记录"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
# 验证必填字段
|
||||
if not data.get('date'):
|
||||
@@ -65,7 +54,7 @@ def create_timerecord():
|
||||
record_date = datetime.strptime(data['date'], '%Y-%m-%d').date()
|
||||
|
||||
# 检查是否为休息日
|
||||
holidays = session.query(Holiday).all()
|
||||
holidays = db.session.query(Holiday).all()
|
||||
holiday_info = is_holiday(record_date, holidays)
|
||||
|
||||
# 计算工时
|
||||
@@ -93,18 +82,17 @@ def create_timerecord():
|
||||
week_info=week_info
|
||||
)
|
||||
|
||||
session.add(record)
|
||||
session.commit()
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
|
||||
result = record.to_dict()
|
||||
if record.date:
|
||||
result['day_of_week'] = get_day_of_week_chinese(record.date)
|
||||
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['PUT'])
|
||||
@@ -112,11 +100,9 @@ def update_timerecord(record_id):
|
||||
"""更新工时记录"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
record = session.query(TimeRecord).get(record_id)
|
||||
record = db.session.query(TimeRecord).get(record_id)
|
||||
if not record:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||
|
||||
# 更新字段
|
||||
@@ -144,47 +130,41 @@ def update_timerecord(record_id):
|
||||
# 更新工作日状态
|
||||
record.is_working_on_holiday = record.is_holiday and record.hours not in ['-', '0:00']
|
||||
|
||||
session.commit()
|
||||
db.session.commit()
|
||||
|
||||
result = record.to_dict()
|
||||
if record.date:
|
||||
result['day_of_week'] = get_day_of_week_chinese(record.date)
|
||||
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['DELETE'])
|
||||
def delete_timerecord(record_id):
|
||||
"""删除工时记录"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
record = session.query(TimeRecord).get(record_id)
|
||||
record = db.session.query(TimeRecord).get(record_id)
|
||||
if not record:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||
|
||||
session.delete(record)
|
||||
session.commit()
|
||||
session.close()
|
||||
db.session.delete(record)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '记录已删除'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords/check_holiday/<string:date_str>', methods=['GET'])
|
||||
def check_holiday(date_str):
|
||||
"""检查指定日期是否为休息日"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
check_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
holidays = session.query(Holiday).all()
|
||||
holidays = db.session.query(Holiday).all()
|
||||
holiday_info = is_holiday(check_date, holidays)
|
||||
|
||||
result = {
|
||||
@@ -196,7 +176,6 @@ def check_holiday(date_str):
|
||||
'week_info': get_week_info(check_date)
|
||||
}
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from flask import Flask, render_template
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models.models import Base
|
||||
from backend.api.projects import projects_bp
|
||||
from backend.api.timerecords import timerecords_bp
|
||||
from backend.api.statistics import statistics_bp
|
||||
from models.models import db
|
||||
from api.projects import projects_bp
|
||||
from api.timerecords import timerecords_bp
|
||||
from api.statistics import statistics_bp
|
||||
from api.data_import import data_import_bp
|
||||
import os
|
||||
|
||||
def create_app():
|
||||
@@ -15,17 +15,27 @@ def create_app():
|
||||
# 启用CORS支持
|
||||
CORS(app)
|
||||
|
||||
# 确保数据目录存在
|
||||
os.makedirs('data', exist_ok=True)
|
||||
# 数据库配置
|
||||
# 获取项目根目录下的data文件夹路径
|
||||
data_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data')
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
db_path = os.path.join(data_dir, 'timetrack.db')
|
||||
|
||||
# 创建数据库表
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Base.metadata.create_all(engine)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
# 初始化数据库
|
||||
db.init_app(app)
|
||||
|
||||
# 在应用上下文中创建数据库表
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(projects_bp)
|
||||
app.register_blueprint(timerecords_bp)
|
||||
app.register_blueprint(statistics_bp)
|
||||
app.register_blueprint(data_import_bp)
|
||||
|
||||
# 主页路由
|
||||
@app.route('/')
|
||||
@@ -44,6 +54,10 @@ def create_app():
|
||||
def statistics():
|
||||
return render_template('statistics.html')
|
||||
|
||||
@app.route('/import')
|
||||
def import_page():
|
||||
return render_template('import.html')
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
BIN
backend/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/models/__pycache__/utils.cpython-313.pyc
Normal file
BIN
backend/models/__pycache__/utils.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,16 +1,16 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Date, Time, ForeignKey, Enum
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
Base = declarative_base()
|
||||
db = SQLAlchemy()
|
||||
|
||||
class ProjectType(enum.Enum):
|
||||
TRADITIONAL = "traditional" # 传统项目
|
||||
PSI = "psi" # PSI项目
|
||||
|
||||
class Project(Base):
|
||||
class Project(db.Model):
|
||||
"""项目表模型"""
|
||||
__tablename__ = 'projects'
|
||||
|
||||
@@ -26,6 +26,8 @@ class Project(Base):
|
||||
contract_number = Column(String(100)) # 合同号,PSI项目必填
|
||||
|
||||
description = Column(Text)
|
||||
start_date = Column(Date, nullable=True) # 项目开始时间
|
||||
end_date = Column(Date, nullable=True) # 项目结束时间
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -42,12 +44,14 @@ class Project(Base):
|
||||
'customer_name': self.customer_name,
|
||||
'contract_number': self.contract_number,
|
||||
'description': self.description,
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
class TimeRecord(Base):
|
||||
class TimeRecord(db.Model):
|
||||
"""工时记录表模型"""
|
||||
__tablename__ = 'time_records'
|
||||
|
||||
@@ -88,7 +92,7 @@ class TimeRecord(Base):
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
class Holiday(Base):
|
||||
class Holiday(db.Model):
|
||||
"""休息日配置表模型"""
|
||||
__tablename__ = 'holidays'
|
||||
|
||||
@@ -109,7 +113,7 @@ class Holiday(Base):
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
class CutoffPeriod(Base):
|
||||
class CutoffPeriod(db.Model):
|
||||
"""Cut-Off周期表模型"""
|
||||
__tablename__ = 'cutoff_periods'
|
||||
|
||||
@@ -135,3 +139,28 @@ class CutoffPeriod(Base):
|
||||
'month': self.month,
|
||||
'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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from backend.models.models import Holiday
|
||||
|
||||
def is_weekend(date: datetime.date) -> bool:
|
||||
"""判断是否为周末"""
|
||||
return date.weekday() >= 5 # 周六=5, 周日=6
|
||||
|
||||
def is_holiday(date: datetime.date, holidays: List[Holiday] = None) -> Dict[str, Any]:
|
||||
def is_holiday(date: datetime.date, holidays: list = None) -> Dict[str, Any]:
|
||||
"""检测指定日期是否为休息日"""
|
||||
day_of_week = date.weekday() # 0=周一, 6=周日
|
||||
is_weekend_day = day_of_week >= 5 # 周六、周日
|
||||
|
||||
BIN
data/timetrack.db
Normal file
BIN
data/timetrack.db
Normal file
Binary file not shown.
@@ -1,7 +1,5 @@
|
||||
Flask==2.3.3
|
||||
Flask-CORS==4.0.0
|
||||
SQLAlchemy==2.0.23
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
SQLAlchemy==1.4.54
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
python-dateutil==2.8.2
|
||||
openpyxl==3.1.2
|
||||
pandas==2.1.4
|
||||
580
static/css/styles.css
Normal file
580
static/css/styles.css
Normal 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
373
static/js/common.js
Normal 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()">×</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
210
static/js/dashboard.js
Normal 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
386
static/js/projects.js
Normal 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
485
static/js/statistics.js
Normal 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
347
static/js/timerecords.js
Normal 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
207
templates/import.html
Normal 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;">手动导入数据 ▾</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 ? '手动导入数据 ▾' : '手动导入数据 ▴';
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
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>
|
||||
@@ -17,6 +17,7 @@
|
||||
<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">导入历史</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
224
templates/projects.html
Normal file
224
templates/projects.html
Normal 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')">×</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')">×</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')">×</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
234
templates/statistics.html
Normal 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')">×</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')">×</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
167
templates/timerecords.html
Normal 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')">×</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>
|
||||
Reference in New Issue
Block a user