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