feat(time-tracking): 添加个人工时记录系统设计文档
- 完成系统架构和数据模型设计,包括项目、工时记录、休息日和周期表模型 - 设计项目管理模块,支持传统项目与PSI项目管理及批量导入功能 - 规划工时记录模块,含日期、事件描述、项目选择及工时计算规则 - 定义休息日分类,支持周末、国定节假日、个人假期及调休工时管理 - 制定统计分析模块设计,支持按Cut-Off周期的周统计与项目工时分布 - 设计周期管理模块,提供周期设置及预设模板功能 - 制定用户界面布局及各页面表单、样式设计方案 - 规划RESTful API端点,涵盖项目、工时记录、休息日、周期及统计数据操作 - 设计数据流示意,阐明操作流程及前后端交互逻辑 - 制定数据存储方案,包括SQLite数据库配置及备份导出机制
This commit is contained in:
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 个人工时记录网站
|
||||
|
||||
## 安装和运行
|
||||
|
||||
1. 创建虚拟环境(可选):
|
||||
```bash
|
||||
python -m venv venv
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
# Linux/Mac
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. 运行应用:
|
||||
```bash
|
||||
python backend/app.py
|
||||
```
|
||||
|
||||
4. 在浏览器中访问:http://localhost:5000
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 项目管理(传统项目和PSI项目)
|
||||
- 工时记录和计算
|
||||
- 休息日标记和工时统计
|
||||
- Cut-Off周期管理
|
||||
- 每周工时统计
|
||||
- 数据导入导出
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
time/
|
||||
├── backend/ # Python Flask 后端
|
||||
├── frontend/ # 前端页面
|
||||
├── static/ # 静态资源
|
||||
├── templates/ # HTML模板
|
||||
├── data/ # 数据库文件
|
||||
└── requirements.txt # Python依赖
|
||||
```
|
||||
1
backend/api/__init__.py
Normal file
1
backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API包初始化文件
|
||||
245
backend/api/projects.py
Normal file
245
backend/api/projects.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models.models import Project, ProjectType
|
||||
from backend.models.utils import *
|
||||
import csv
|
||||
import io
|
||||
|
||||
projects_bp = Blueprint('projects', __name__)
|
||||
|
||||
def get_db_session():
|
||||
"""获取数据库会话"""
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
@projects_bp.route('/api/projects', methods=['GET'])
|
||||
def get_projects():
|
||||
"""获取所有项目列表"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
projects = session.query(Project).filter_by(is_active=True).all()
|
||||
|
||||
result = []
|
||||
for project in projects:
|
||||
project_dict = project.to_dict()
|
||||
# 为前端显示格式化项目信息
|
||||
if project.project_type == ProjectType.TRADITIONAL:
|
||||
project_dict['display_name'] = f"{project.project_name} ({project.customer_name}-{project.project_code})"
|
||||
else: # PSI项目
|
||||
project_dict['display_name'] = f"{project.project_name} ({project.customer_name}-{project.contract_number})"
|
||||
|
||||
result.append(project_dict)
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects', methods=['POST'])
|
||||
def create_project():
|
||||
"""创建新项目"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
# 验证必填字段
|
||||
if not data.get('project_name') or not data.get('customer_name') or not data.get('project_type'):
|
||||
return jsonify({'success': False, 'error': '项目名称、客户名和项目类型为必填项'}), 400
|
||||
|
||||
# 根据项目类型设置字段
|
||||
if data['project_type'] == 'traditional':
|
||||
if not data.get('project_code'):
|
||||
return jsonify({'success': False, 'error': '传统项目需要填写项目代码'}), 400
|
||||
project_code = data['project_code']
|
||||
contract_number = None
|
||||
else: # PSI项目
|
||||
if not data.get('contract_number'):
|
||||
return jsonify({'success': False, 'error': 'PSI项目需要填写合同号'}), 400
|
||||
project_code = 'PSI-PROJ' # PSI项目统一代码
|
||||
contract_number = data['contract_number']
|
||||
|
||||
# 检查唯一性约束
|
||||
existing_project = None
|
||||
if data['project_type'] == 'traditional':
|
||||
# 传统项目:客户名+项目代码唯一
|
||||
existing_project = session.query(Project).filter_by(
|
||||
customer_name=data['customer_name'],
|
||||
project_code=project_code,
|
||||
project_type=ProjectType.TRADITIONAL
|
||||
).first()
|
||||
else:
|
||||
# PSI项目:客户名+合同号唯一
|
||||
existing_project = session.query(Project).filter_by(
|
||||
customer_name=data['customer_name'],
|
||||
contract_number=contract_number,
|
||||
project_type=ProjectType.PSI
|
||||
).first()
|
||||
|
||||
if existing_project:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '项目已存在'}), 400
|
||||
|
||||
# 创建新项目
|
||||
project = Project(
|
||||
project_name=data['project_name'],
|
||||
project_type=ProjectType(data['project_type']),
|
||||
project_code=project_code,
|
||||
customer_name=data['customer_name'],
|
||||
contract_number=contract_number,
|
||||
description=data.get('description', '')
|
||||
)
|
||||
|
||||
session.add(project)
|
||||
session.commit()
|
||||
|
||||
result = project.to_dict()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
|
||||
def update_project(project_id):
|
||||
"""更新项目信息"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
project = session.query(Project).get(project_id)
|
||||
if not project:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '项目不存在'}), 404
|
||||
|
||||
# 更新字段
|
||||
if 'project_name' in data:
|
||||
project.project_name = data['project_name']
|
||||
if 'description' in data:
|
||||
project.description = data['description']
|
||||
|
||||
session.commit()
|
||||
result = project.to_dict()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
|
||||
def delete_project(project_id):
|
||||
"""删除项目(软删除)"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
project = session.query(Project).get(project_id)
|
||||
if not project:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '项目不存在'}), 404
|
||||
|
||||
project.is_active = False
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'message': '项目已删除'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@projects_bp.route('/api/projects/import', methods=['POST'])
|
||||
def import_projects():
|
||||
"""批量导入项目"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'error': '请选择CSV文件'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '' or not file.filename.endswith('.csv'):
|
||||
return jsonify({'success': False, 'error': '请选择有效的CSV文件'}), 400
|
||||
|
||||
session = get_db_session()
|
||||
|
||||
# 读取CSV文件
|
||||
stream = io.StringIO(file.stream.read().decode("utf-8"))
|
||||
csv_reader = csv.DictReader(stream)
|
||||
|
||||
created_count = 0
|
||||
errors = []
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2):
|
||||
try:
|
||||
# 验证必填字段
|
||||
if not row.get('项目名称') or not row.get('客户名') or not row.get('项目类型'):
|
||||
errors.append(f"第{row_num}行:项目名称、客户名和项目类型为必填项")
|
||||
continue
|
||||
|
||||
project_type = row['项目类型'].lower()
|
||||
if project_type not in ['traditional', 'psi']:
|
||||
errors.append(f"第{row_num}行:项目类型只能是 traditional 或 psi")
|
||||
continue
|
||||
|
||||
# 根据项目类型设置字段
|
||||
if project_type == 'traditional':
|
||||
if not row.get('项目代码'):
|
||||
errors.append(f"第{row_num}行:传统项目需要填写项目代码")
|
||||
continue
|
||||
project_code = row['项目代码']
|
||||
contract_number = None
|
||||
else: # PSI项目
|
||||
if not row.get('合同号'):
|
||||
errors.append(f"第{row_num}行:PSI项目需要填写合同号")
|
||||
continue
|
||||
project_code = 'PSI-PROJ'
|
||||
contract_number = row['合同号']
|
||||
|
||||
# 检查重复
|
||||
existing_project = None
|
||||
if project_type == 'traditional':
|
||||
existing_project = session.query(Project).filter_by(
|
||||
customer_name=row['客户名'],
|
||||
project_code=project_code,
|
||||
project_type=ProjectType.TRADITIONAL
|
||||
).first()
|
||||
else:
|
||||
existing_project = session.query(Project).filter_by(
|
||||
customer_name=row['客户名'],
|
||||
contract_number=contract_number,
|
||||
project_type=ProjectType.PSI
|
||||
).first()
|
||||
|
||||
if existing_project:
|
||||
errors.append(f"第{row_num}行:项目已存在")
|
||||
continue
|
||||
|
||||
# 创建项目
|
||||
project = Project(
|
||||
project_name=row['项目名称'],
|
||||
project_type=ProjectType(project_type),
|
||||
project_code=project_code,
|
||||
customer_name=row['客户名'],
|
||||
contract_number=contract_number,
|
||||
description=row.get('描述', '')
|
||||
)
|
||||
|
||||
session.add(project)
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"第{row_num}行:{str(e)}")
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'成功导入{created_count}个项目',
|
||||
'created_count': created_count,
|
||||
'errors': errors
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
267
backend/api/statistics.py
Normal file
267
backend/api/statistics.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine, and_
|
||||
from backend.models.models import TimeRecord, Project, CutoffPeriod
|
||||
from backend.models.utils import *
|
||||
from datetime import datetime, date, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
statistics_bp = Blueprint('statistics', __name__)
|
||||
|
||||
def get_db_session():
|
||||
"""获取数据库会话"""
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
@statistics_bp.route('/api/statistics/weekly', methods=['GET'])
|
||||
def get_weekly_statistics():
|
||||
"""获取周统计数据"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
# 获取查询参数
|
||||
period_id = request.args.get('period_id')
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
# 如果指定了周期ID,使用周期的日期范围
|
||||
if period_id:
|
||||
period = session.query(CutoffPeriod).get(int(period_id))
|
||||
if not period:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '周期不存在'}), 404
|
||||
|
||||
start_date = period.start_date
|
||||
end_date = period.end_date
|
||||
target_hours = period.target_hours
|
||||
period_info = period.to_dict()
|
||||
else:
|
||||
# 使用指定的日期范围
|
||||
if not start_date or not end_date:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '请提供开始日期和结束日期'}), 400
|
||||
|
||||
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
target_hours = 40 # 默认目标工时
|
||||
period_info = {
|
||||
'period_name': f"{start_date.strftime('%m月%d日')}-{end_date.strftime('%m月%d日')}",
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat(),
|
||||
'target_hours': target_hours
|
||||
}
|
||||
|
||||
# 查询该时间范围内的所有工时记录
|
||||
records = session.query(TimeRecord).filter(
|
||||
and_(TimeRecord.date >= start_date, TimeRecord.date <= end_date)
|
||||
).order_by(TimeRecord.date).all()
|
||||
|
||||
# 生成完整的日期范围
|
||||
current_date = start_date
|
||||
daily_records = []
|
||||
record_dict = {}
|
||||
|
||||
# 将记录按日期分组
|
||||
for record in records:
|
||||
if record.date not in record_dict:
|
||||
record_dict[record.date] = []
|
||||
record_dict[record.date].append(record)
|
||||
|
||||
# 生成每日汇总
|
||||
while current_date <= end_date:
|
||||
day_records = record_dict.get(current_date, [])
|
||||
|
||||
if day_records:
|
||||
# 如果有记录,汇总该日的数据
|
||||
total_hours = sum([format_hours_to_decimal(r.hours) for r in day_records if r.hours not in ['-', '0:00']])
|
||||
event_descriptions = [r.event_description for r in day_records if r.event_description and r.event_description != '-']
|
||||
activity_nums = [r.activity_num for r in day_records if r.activity_num and r.activity_num != '-']
|
||||
project_names = []
|
||||
|
||||
for r in day_records:
|
||||
if r.project and r.project.project_name:
|
||||
if r.project.project_type.value == 'traditional':
|
||||
project_names.append(f"{r.project.customer_name} {r.project.project_code}")
|
||||
else:
|
||||
project_names.append(f"{r.project.customer_name} {r.project.contract_number}")
|
||||
|
||||
# 获取第一条记录的时间信息
|
||||
first_record = day_records[0]
|
||||
|
||||
daily_record = {
|
||||
'date': current_date.isoformat(),
|
||||
'day_of_week': get_day_of_week_chinese(current_date),
|
||||
'event': '; '.join(event_descriptions) if event_descriptions else '-',
|
||||
'project': '; '.join(set(project_names)) if project_names else '-',
|
||||
'start_time': first_record.start_time.strftime('%H:%M') if first_record.start_time else '-',
|
||||
'end_time': first_record.end_time.strftime('%H:%M') if first_record.end_time else '-',
|
||||
'activity_num': '; '.join(activity_nums) if activity_nums else '-',
|
||||
'hours': format_decimal_to_hours(total_hours) if total_hours > 0 else ('0:00' if first_record.is_holiday else '-'),
|
||||
'is_holiday': first_record.is_holiday,
|
||||
'holiday_type': first_record.holiday_type,
|
||||
'is_working_on_holiday': any(r.is_working_on_holiday for r in day_records)
|
||||
}
|
||||
else:
|
||||
# 如果没有记录,生成默认记录
|
||||
holidays = session.query(Holiday).all()
|
||||
holiday_info = is_holiday(current_date, holidays)
|
||||
|
||||
daily_record = {
|
||||
'date': current_date.isoformat(),
|
||||
'day_of_week': get_day_of_week_chinese(current_date),
|
||||
'event': '-',
|
||||
'project': '-',
|
||||
'start_time': '-',
|
||||
'end_time': '-',
|
||||
'activity_num': '-',
|
||||
'hours': '0:00' if holiday_info['is_holiday'] else '-',
|
||||
'is_holiday': holiday_info['is_holiday'],
|
||||
'holiday_type': holiday_info['holiday_type'],
|
||||
'is_working_on_holiday': False
|
||||
}
|
||||
|
||||
daily_records.append(daily_record)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# 计算统计数据
|
||||
workday_total = 0
|
||||
holiday_total = 0
|
||||
working_days = 0
|
||||
holiday_work_days = 0
|
||||
rest_days = 0
|
||||
|
||||
for daily in daily_records:
|
||||
hours_decimal = format_hours_to_decimal(daily['hours'])
|
||||
if daily['is_holiday']:
|
||||
if daily['is_working_on_holiday']:
|
||||
holiday_total += hours_decimal
|
||||
holiday_work_days += 1
|
||||
else:
|
||||
rest_days += 1
|
||||
else:
|
||||
if hours_decimal > 0:
|
||||
workday_total += hours_decimal
|
||||
working_days += 1
|
||||
else:
|
||||
rest_days += 1
|
||||
|
||||
# 项目工时统计
|
||||
project_hours = defaultdict(float)
|
||||
for record in records:
|
||||
if record.project and record.hours not in ['-', '0:00']:
|
||||
hours_decimal = format_hours_to_decimal(record.hours)
|
||||
if record.project.project_type.value == 'traditional':
|
||||
project_key = f"{record.project.customer_name} {record.project.project_code}"
|
||||
else:
|
||||
project_key = f"{record.project.customer_name} {record.project.contract_number}"
|
||||
project_hours[project_key] += hours_decimal
|
||||
|
||||
total_project_hours = sum(project_hours.values())
|
||||
project_stats = []
|
||||
for project_name, hours in project_hours.items():
|
||||
percentage = (hours / total_project_hours * 100) if total_project_hours > 0 else 0
|
||||
project_stats.append({
|
||||
'project': project_name,
|
||||
'hours': format_decimal_to_hours(hours),
|
||||
'percentage': round(percentage, 1)
|
||||
})
|
||||
|
||||
project_stats.sort(key=lambda x: x['percentage'], reverse=True)
|
||||
|
||||
result = {
|
||||
'period': period_info,
|
||||
'daily_records': daily_records,
|
||||
'project_hours': project_stats,
|
||||
'workday_total': format_decimal_to_hours(workday_total),
|
||||
'holiday_total': format_decimal_to_hours(holiday_total),
|
||||
'weekly_total': format_decimal_to_hours(workday_total + holiday_total),
|
||||
'working_days': working_days,
|
||||
'holiday_work_days': holiday_work_days,
|
||||
'rest_days': rest_days,
|
||||
'target_hours': target_hours,
|
||||
'completion_rate': round((workday_total + holiday_total) / target_hours * 100, 1) if target_hours > 0 else 0
|
||||
}
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@statistics_bp.route('/api/statistics/periods', methods=['GET'])
|
||||
def get_cutoff_periods():
|
||||
"""获取Cut-Off周期列表"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
periods = session.query(CutoffPeriod).order_by(CutoffPeriod.start_date.desc()).all()
|
||||
result = [period.to_dict() for period in periods]
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@statistics_bp.route('/api/statistics/periods', methods=['POST'])
|
||||
def create_cutoff_period():
|
||||
"""创建Cut-Off周期"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
# 验证必填字段
|
||||
if not all(key in data for key in ['period_name', 'start_date', 'end_date']):
|
||||
return jsonify({'success': False, 'error': '周期名称、开始日期和结束日期为必填项'}), 400
|
||||
|
||||
start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
|
||||
|
||||
if start_date >= end_date:
|
||||
return jsonify({'success': False, 'error': '开始日期必须早于结束日期'}), 400
|
||||
|
||||
# 计算周数
|
||||
days_diff = (end_date - start_date).days + 1
|
||||
weeks = round(days_diff / 7)
|
||||
|
||||
period = CutoffPeriod(
|
||||
period_name=data['period_name'],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
target_hours=data.get('target_hours', weeks * 40),
|
||||
weeks=weeks,
|
||||
year=start_date.year,
|
||||
month=start_date.month
|
||||
)
|
||||
|
||||
session.add(period)
|
||||
session.commit()
|
||||
|
||||
result = period.to_dict()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@statistics_bp.route('/api/statistics/periods/<int:period_id>', methods=['DELETE'])
|
||||
def delete_cutoff_period(period_id):
|
||||
"""删除Cut-Off周期"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
period = session.query(CutoffPeriod).get(period_id)
|
||||
if not period:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '周期不存在'}), 404
|
||||
|
||||
session.delete(period)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'message': '周期已删除'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
203
backend/api/timerecords.py
Normal file
203
backend/api/timerecords.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine, and_
|
||||
from backend.models.models import TimeRecord, Project, Holiday
|
||||
from backend.models.utils import *
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
|
||||
timerecords_bp = Blueprint('timerecords', __name__)
|
||||
|
||||
def get_db_session():
|
||||
"""获取数据库会话"""
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
@timerecords_bp.route('/api/timerecords', methods=['GET'])
|
||||
def get_timerecords():
|
||||
"""获取工时记录列表"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
# 获取查询参数
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
project_id = request.args.get('project_id')
|
||||
|
||||
query = session.query(TimeRecord)
|
||||
|
||||
# 应用筛选条件
|
||||
if start_date:
|
||||
query = query.filter(TimeRecord.date >= datetime.strptime(start_date, '%Y-%m-%d').date())
|
||||
if end_date:
|
||||
query = query.filter(TimeRecord.date <= datetime.strptime(end_date, '%Y-%m-%d').date())
|
||||
if project_id:
|
||||
query = query.filter(TimeRecord.project_id == int(project_id))
|
||||
|
||||
records = query.order_by(TimeRecord.date.desc()).all()
|
||||
|
||||
result = []
|
||||
for record in records:
|
||||
record_dict = record.to_dict()
|
||||
# 添加星期几信息
|
||||
if record.date:
|
||||
record_dict['day_of_week'] = get_day_of_week_chinese(record.date)
|
||||
result.append(record_dict)
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords', methods=['POST'])
|
||||
def create_timerecord():
|
||||
"""创建工时记录"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
# 验证必填字段
|
||||
if not data.get('date'):
|
||||
return jsonify({'success': False, 'error': '日期为必填项'}), 400
|
||||
|
||||
record_date = datetime.strptime(data['date'], '%Y-%m-%d').date()
|
||||
|
||||
# 检查是否为休息日
|
||||
holidays = session.query(Holiday).all()
|
||||
holiday_info = is_holiday(record_date, holidays)
|
||||
|
||||
# 计算工时
|
||||
hours = data.get('hours', '')
|
||||
if not hours and data.get('start_time') and data.get('end_time'):
|
||||
hours = calculate_hours(data['start_time'], data['end_time'], holiday_info['is_holiday'])
|
||||
elif not hours:
|
||||
hours = '0:00' if holiday_info['is_holiday'] else '-'
|
||||
|
||||
# 获取周信息
|
||||
week_info = get_week_info(record_date)
|
||||
|
||||
# 创建记录
|
||||
record = TimeRecord(
|
||||
date=record_date,
|
||||
event_description=data.get('event_description', ''),
|
||||
project_id=data.get('project_id') if data.get('project_id') else None,
|
||||
start_time=datetime.strptime(data['start_time'], '%H:%M').time() if data.get('start_time') and data['start_time'] != '-' else None,
|
||||
end_time=datetime.strptime(data['end_time'], '%H:%M').time() if data.get('end_time') and data['end_time'] != '-' else None,
|
||||
activity_num=data.get('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
|
||||
)
|
||||
|
||||
session.add(record)
|
||||
session.commit()
|
||||
|
||||
result = record.to_dict()
|
||||
if record.date:
|
||||
result['day_of_week'] = get_day_of_week_chinese(record.date)
|
||||
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['PUT'])
|
||||
def update_timerecord(record_id):
|
||||
"""更新工时记录"""
|
||||
try:
|
||||
data = request.json
|
||||
session = get_db_session()
|
||||
|
||||
record = session.query(TimeRecord).get(record_id)
|
||||
if not record:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||
|
||||
# 更新字段
|
||||
if 'event_description' in data:
|
||||
record.event_description = data['event_description']
|
||||
if 'project_id' in data:
|
||||
record.project_id = data['project_id'] if data['project_id'] else None
|
||||
if 'activity_num' in data:
|
||||
record.activity_num = data['activity_num']
|
||||
|
||||
# 更新时间相关字段
|
||||
if 'start_time' in data:
|
||||
record.start_time = datetime.strptime(data['start_time'], '%H:%M').time() if data['start_time'] and data['start_time'] != '-' else None
|
||||
if 'end_time' in data:
|
||||
record.end_time = datetime.strptime(data['end_time'], '%H:%M').time() if data['end_time'] and data['end_time'] != '-' else None
|
||||
|
||||
# 重新计算工时
|
||||
if 'hours' in data:
|
||||
record.hours = data['hours']
|
||||
elif record.start_time and record.end_time:
|
||||
start_str = record.start_time.strftime('%H:%M')
|
||||
end_str = record.end_time.strftime('%H:%M')
|
||||
record.hours = calculate_hours(start_str, end_str, record.is_holiday)
|
||||
|
||||
# 更新工作日状态
|
||||
record.is_working_on_holiday = record.is_holiday and record.hours not in ['-', '0:00']
|
||||
|
||||
session.commit()
|
||||
|
||||
result = record.to_dict()
|
||||
if record.date:
|
||||
result['day_of_week'] = get_day_of_week_chinese(record.date)
|
||||
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords/<int:record_id>', methods=['DELETE'])
|
||||
def delete_timerecord(record_id):
|
||||
"""删除工时记录"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
record = session.query(TimeRecord).get(record_id)
|
||||
if not record:
|
||||
session.close()
|
||||
return jsonify({'success': False, 'error': '记录不存在'}), 404
|
||||
|
||||
session.delete(record)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
return jsonify({'success': True, 'message': '记录已删除'})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@timerecords_bp.route('/api/timerecords/check_holiday/<string:date_str>', methods=['GET'])
|
||||
def check_holiday(date_str):
|
||||
"""检查指定日期是否为休息日"""
|
||||
try:
|
||||
session = get_db_session()
|
||||
|
||||
check_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
holidays = session.query(Holiday).all()
|
||||
holiday_info = is_holiday(check_date, holidays)
|
||||
|
||||
result = {
|
||||
'date': date_str,
|
||||
'is_holiday': holiday_info['is_holiday'],
|
||||
'holiday_type': holiday_info['holiday_type'],
|
||||
'holiday_name': holiday_info['holiday_name'],
|
||||
'day_of_week': get_day_of_week_chinese(check_date),
|
||||
'week_info': get_week_info(check_date)
|
||||
}
|
||||
|
||||
session.close()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
51
backend/app.py
Normal file
51
backend/app.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from flask import Flask, render_template
|
||||
from flask_cors import CORS
|
||||
from sqlalchemy import create_engine
|
||||
from backend.models.models import Base
|
||||
from backend.api.projects import projects_bp
|
||||
from backend.api.timerecords import timerecords_bp
|
||||
from backend.api.statistics import statistics_bp
|
||||
import os
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__,
|
||||
template_folder='../templates',
|
||||
static_folder='../static')
|
||||
|
||||
# 启用CORS支持
|
||||
CORS(app)
|
||||
|
||||
# 确保数据目录存在
|
||||
os.makedirs('data', exist_ok=True)
|
||||
|
||||
# 创建数据库表
|
||||
engine = create_engine('sqlite:///data/timetrack.db')
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(projects_bp)
|
||||
app.register_blueprint(timerecords_bp)
|
||||
app.register_blueprint(statistics_bp)
|
||||
|
||||
# 主页路由
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/projects')
|
||||
def projects():
|
||||
return render_template('projects.html')
|
||||
|
||||
@app.route('/timerecords')
|
||||
def timerecords():
|
||||
return render_template('timerecords.html')
|
||||
|
||||
@app.route('/statistics')
|
||||
def statistics():
|
||||
return render_template('statistics.html')
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
1
backend/models/__init__.py
Normal file
1
backend/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 模型包初始化文件
|
||||
137
backend/models/models.py
Normal file
137
backend/models/models.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Date, Time, ForeignKey, Enum
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class ProjectType(enum.Enum):
|
||||
TRADITIONAL = "traditional" # 传统项目
|
||||
PSI = "psi" # PSI项目
|
||||
|
||||
class Project(Base):
|
||||
"""项目表模型"""
|
||||
__tablename__ = 'projects'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
project_name = Column(String(200), nullable=False)
|
||||
project_type = Column(Enum(ProjectType), nullable=False)
|
||||
|
||||
# 通用字段
|
||||
project_code = Column(String(50), nullable=False)
|
||||
customer_name = Column(String(200), nullable=False)
|
||||
|
||||
# PSI项目特有字段
|
||||
contract_number = Column(String(100)) # 合同号,PSI项目必填
|
||||
|
||||
description = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关联关系
|
||||
time_records = relationship("TimeRecord", back_populates="project")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'project_name': self.project_name,
|
||||
'project_type': self.project_type.value if self.project_type else None,
|
||||
'project_code': self.project_code,
|
||||
'customer_name': self.customer_name,
|
||||
'contract_number': self.contract_number,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
class TimeRecord(Base):
|
||||
"""工时记录表模型"""
|
||||
__tablename__ = 'time_records'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
date = Column(Date, nullable=False)
|
||||
event_description = Column(String(500)) # 事件描述
|
||||
project_id = Column(Integer, ForeignKey('projects.id'))
|
||||
start_time = Column(Time) # 可为空,支持"-"占位符
|
||||
end_time = Column(Time) # 可为空,支持"-"占位符
|
||||
activity_num = Column(String(100)) # Activity Num
|
||||
hours = Column(String(20)) # 工时,支持"2:42"或"8:00:00"格式
|
||||
is_holiday = Column(Boolean, default=False) # 是否为休息日
|
||||
is_working_on_holiday = Column(Boolean, default=False) # 休息日是否工作
|
||||
holiday_type = Column(String(50)) # 休息日类型
|
||||
week_info = Column(String(50)) # 周信息
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关联关系
|
||||
project = relationship("Project", back_populates="time_records")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'date': self.date.isoformat() if self.date else None,
|
||||
'event_description': self.event_description,
|
||||
'project_id': self.project_id,
|
||||
'start_time': self.start_time.strftime('%H:%M') if self.start_time else None,
|
||||
'end_time': self.end_time.strftime('%H:%M') if self.end_time else None,
|
||||
'activity_num': self.activity_num,
|
||||
'hours': self.hours,
|
||||
'is_holiday': self.is_holiday,
|
||||
'is_working_on_holiday': self.is_working_on_holiday,
|
||||
'holiday_type': self.holiday_type,
|
||||
'week_info': self.week_info,
|
||||
'project': self.project.to_dict() if self.project 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
|
||||
}
|
||||
|
||||
class Holiday(Base):
|
||||
"""休息日配置表模型"""
|
||||
__tablename__ = 'holidays'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
date = Column(Date, nullable=False, unique=True)
|
||||
holiday_name = Column(String(100)) # 节假日名称
|
||||
holiday_type = Column(String(50), nullable=False) # weekend/national_holiday/personal_leave/makeup_day
|
||||
is_working_day = Column(Boolean, default=False) # 调休工作日标记
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'date': self.date.isoformat() if self.date else None,
|
||||
'holiday_name': self.holiday_name,
|
||||
'holiday_type': self.holiday_type,
|
||||
'is_working_day': self.is_working_day,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
class CutoffPeriod(Base):
|
||||
"""Cut-Off周期表模型"""
|
||||
__tablename__ = 'cutoff_periods'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
period_name = Column(String(50), nullable=False)
|
||||
start_date = Column(Date, nullable=False)
|
||||
end_date = Column(Date, nullable=False)
|
||||
target_hours = Column(Integer, default=160)
|
||||
weeks = Column(Integer, default=4)
|
||||
year = Column(Integer, nullable=False)
|
||||
month = Column(Integer, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'period_name': self.period_name,
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'target_hours': self.target_hours,
|
||||
'weeks': self.weeks,
|
||||
'year': self.year,
|
||||
'month': self.month,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
113
backend/models/utils.py
Normal file
113
backend/models/utils.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from backend.models.models import Holiday
|
||||
|
||||
def is_weekend(date: datetime.date) -> bool:
|
||||
"""判断是否为周末"""
|
||||
return date.weekday() >= 5 # 周六=5, 周日=6
|
||||
|
||||
def is_holiday(date: datetime.date, holidays: List[Holiday] = None) -> Dict[str, Any]:
|
||||
"""检测指定日期是否为休息日"""
|
||||
day_of_week = date.weekday() # 0=周一, 6=周日
|
||||
is_weekend_day = day_of_week >= 5 # 周六、周日
|
||||
|
||||
# 检查是否为配置的节假日
|
||||
configured_holiday = None
|
||||
if holidays:
|
||||
for holiday in holidays:
|
||||
if holiday.date == date:
|
||||
configured_holiday = holiday
|
||||
break
|
||||
|
||||
is_configured_holiday = configured_holiday is not None
|
||||
|
||||
return {
|
||||
'is_holiday': is_weekend_day or is_configured_holiday,
|
||||
'holiday_type': 'weekend' if is_weekend_day else (configured_holiday.holiday_type if configured_holiday else None),
|
||||
'holiday_name': configured_holiday.holiday_name if configured_holiday else None,
|
||||
'is_working_day': configured_holiday.is_working_day if configured_holiday else False
|
||||
}
|
||||
|
||||
def calculate_hours(start_time: Optional[str], end_time: Optional[str], is_holiday_flag: bool = False) -> str:
|
||||
"""工时计算函数"""
|
||||
if not start_time or not end_time or start_time == '-' or end_time == '-':
|
||||
return '0:00' if is_holiday_flag else '-' # 休息日默认显示0:00而不是-
|
||||
|
||||
try:
|
||||
start = datetime.datetime.strptime(start_time, '%H:%M')
|
||||
end = datetime.datetime.strptime(end_time, '%H:%M')
|
||||
|
||||
# 处理跨日情况
|
||||
if end < start:
|
||||
end += datetime.timedelta(days=1)
|
||||
|
||||
diff = end - start
|
||||
total_minutes = int(diff.total_seconds() / 60)
|
||||
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
|
||||
return f"{hours}:{minutes:02d}"
|
||||
except ValueError:
|
||||
return '0:00'
|
||||
|
||||
def format_hours_to_decimal(hours: str) -> float:
|
||||
"""工时格式转换函数:将"2:42"格式转换为小数格式用于计算"""
|
||||
if hours == '-' or not hours:
|
||||
return 0.0
|
||||
|
||||
if ':' in hours:
|
||||
try:
|
||||
parts = hours.split(':')
|
||||
h = int(parts[0])
|
||||
m = int(parts[1]) if len(parts) > 1 else 0
|
||||
return h + (m / 60)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
return float(hours)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
def format_decimal_to_hours(decimal_hours: float) -> str:
|
||||
"""将小数工时转换回"HH:MM"格式"""
|
||||
hours = int(decimal_hours)
|
||||
minutes = int((decimal_hours - hours) * 60)
|
||||
return f"{hours}:{minutes:02d}"
|
||||
|
||||
def calculate_weekly_hours(records: list) -> Dict[str, float]:
|
||||
"""工时统计函数(区分工作日和休息日)"""
|
||||
workday_hours = sum([
|
||||
format_hours_to_decimal(r.get('hours', '0:00'))
|
||||
for r in records
|
||||
if not r.get('is_holiday', False) and r.get('hours') not in ['-', '0:00', None]
|
||||
])
|
||||
|
||||
holiday_hours = sum([
|
||||
format_hours_to_decimal(r.get('hours', '0:00'))
|
||||
for r in records
|
||||
if r.get('is_holiday', False) and r.get('hours') not in ['-', '0:00', None]
|
||||
])
|
||||
|
||||
return {
|
||||
'workday_hours': workday_hours,
|
||||
'holiday_hours': holiday_hours,
|
||||
'total_hours': workday_hours + holiday_hours
|
||||
}
|
||||
|
||||
def get_week_info(date: datetime.date) -> str:
|
||||
"""获取周信息,如"51周/53周" """
|
||||
year = date.year
|
||||
week_num = date.isocalendar()[1]
|
||||
|
||||
# 计算该年总共有多少周
|
||||
last_day = datetime.date(year, 12, 31)
|
||||
total_weeks = last_day.isocalendar()[1]
|
||||
|
||||
return f"{week_num}周/{total_weeks}周"
|
||||
|
||||
def get_day_of_week_chinese(date: datetime.date) -> str:
|
||||
"""获取中文星期"""
|
||||
weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
return weekdays[date.weekday()]
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Flask==2.3.3
|
||||
Flask-CORS==4.0.0
|
||||
SQLAlchemy==2.0.23
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
python-dateutil==2.8.2
|
||||
openpyxl==3.1.2
|
||||
pandas==2.1.4
|
||||
93
templates/index.html
Normal file
93
templates/index.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!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 active">首页</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>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="container">
|
||||
<div class="welcome-section">
|
||||
<h2>欢迎使用个人工时记录系统</h2>
|
||||
<p>一个简单易用的个人工时管理工具,支持项目分类、休息日标记和周统计功能。</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-cards">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>项目管理</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>管理传统项目和PSI项目,支持批量导入和项目分类。</p>
|
||||
<a href="/projects" class="btn btn-primary">进入项目管理</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>工时记录</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>记录每日工作时间,自动识别休息日并支持加班记录。</p>
|
||||
<a href="/timerecords" class="btn btn-primary">记录工时</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>统计分析</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>按周统计工时,支持Cut-Off周期管理和项目工时分析。</p>
|
||||
<a href="/statistics" class="btn btn-primary">查看统计</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-stats">
|
||||
<h3>快速统计</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="total-projects">-</div>
|
||||
<div class="stat-label">活跃项目</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="this-week-hours">-</div>
|
||||
<div class="stat-label">本周工时</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="this-month-records">-</div>
|
||||
<div class="stat-label">本月记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-records">
|
||||
<h3>最近记录</h3>
|
||||
<div id="recent-records-list">
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/common.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user