工时统计系统

工时统计系统

目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
work_time_tracker/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 主应用
│ ├── models.py # 数据模型
│ ├── database.py # 数据库连接
│ ├── schemas.py # Pydantic 模型
│ ├── crud.py # 数据库操作函数
│ ├── utils.py # 工具函数
├── static/
│ ├── css/
│ │ └── styles.css # 样式文件
│ └── js/
│ └── script.js # 前端JavaScript
│── templates/
│ ├── index.html # 主页面
│ ├── base.html # 基础模板

styles.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.card {
margin-bottom: 20px;
}

.badge {
font-size: 0.9rem;
}

.table th, .table td {
vertical-align: middle;
}

.record-checkbox {
cursor: pointer;
}

script.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
// 编辑记录
async function editRecord(recordId) {
try {
const response = await fetch(`/records/${recordId}`);
if (!response.ok) {
throw new Error('获取记录失败');
}

const record = await response.json();

// 填充表单
document.getElementById('edit_record_id').value = record.id;
document.getElementById('edit_date').value = record.date;
document.getElementById('edit_start_time').value = record.start_time.substring(0, 5);
document.getElementById('edit_end_time').value = record.end_time.substring(0, 5);
document.getElementById('edit_lunch_break_start').value = record.lunch_break_start.substring(0, 5);
document.getElementById('edit_lunch_break_end').value = record.lunch_break_end.substring(0, 5);
document.getElementById('edit_dinner_break_start').value = record.dinner_break_start.substring(0, 5);
document.getElementById('edit_dinner_break_end').value = record.dinner_break_end.substring(0, 5);

// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('editRecordModal'));
modal.show();
} catch (error) {
console.error('Error:', error);
alert('获取记录失败: ' + error.message);
}
}

// 保存编辑后的记录
async function saveEditedRecord() {
const recordId = document.getElementById('edit_record_id').value;

// 获取表单数据
const dateValue = document.getElementById('edit_date').value;
const startTimeValue = document.getElementById('edit_start_time').value;
const endTimeValue = document.getElementById('edit_end_time').value;
const lunchStartValue = document.getElementById('edit_lunch_break_start').value;
const lunchEndValue = document.getElementById('edit_lunch_break_end').value;
const dinnerStartValue = document.getElementById('edit_dinner_break_start').value;
const dinnerEndValue = document.getElementById('edit_dinner_break_end').value;

// 构建请求数据
const formData = {
date: dateValue,
start_time: startTimeValue,
end_time: endTimeValue,
lunch_break_start: lunchStartValue,
lunch_break_end: lunchEndValue,
dinner_break_start: dinnerStartValue,
dinner_break_end: dinnerEndValue
};

try {
const response = await fetch(`/records/${recordId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '更新记录失败');
}

// 关闭模态框并刷新页面
const modal = bootstrap.Modal.getInstance(document.getElementById('editRecordModal'));
modal.hide();

// 刷新页面
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('更新记录失败: ' + error.message);
}
}

// 删除记录
async function deleteRecord(recordId) {
if (!confirm('确定要删除这条记录吗?')) {
return;
}

try {
const response = await fetch(`/records/${recordId}`, {
method: 'DELETE',
});

if (!response.ok) {
throw new Error('删除记录失败');
}

// 从DOM中移除记录行
const row = document.getElementById(`record-row-${recordId}`);
if (row) {
row.remove();
}

// 刷新页面以更新统计数据
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('删除记录失败: ' + error.message);
}
}

// 批量删除相关函数
function toggleSelectAll() {
const selectAllCheckbox = document.getElementById('selectAll');
const recordCheckboxes = document.querySelectorAll('.record-checkbox');

recordCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});

updateBatchDeleteButton();
}

function updateBatchDeleteButton() {
const recordCheckboxes = document.querySelectorAll('.record-checkbox:checked');
const batchDeleteBtn = document.getElementById('batchDeleteBtn');

if (recordCheckboxes.length > 0) {
batchDeleteBtn.style.display = 'block';
} else {
batchDeleteBtn.style.display = 'none';
}
}

async function deleteSelectedRecords() {
const selectedCheckboxes = document.querySelectorAll('.record-checkbox:checked');
if (selectedCheckboxes.length === 0) {
return;
}

if (!confirm(`确定要删除选中的 ${selectedCheckboxes.length} 条记录吗?`)) {
return;
}

const recordIds = Array.from(selectedCheckboxes).map(checkbox => parseInt(checkbox.dataset.id));

try {
const response = await fetch('/records/delete-multiple/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ record_ids: recordIds }),
});

if (!response.ok) {
throw new Error('批量删除记录失败');
}

// 刷新页面
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('批量删除记录失败: ' + error.message);
}
}

base.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!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="{{ url_for('static', path='/css/styles.css') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-4">
<h1 class="mb-4">工时记录系统</h1>

{% block content %}{% endblock %}
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', path='/js/script.js') }}"></script>
</body>
</html>

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
{% extends "base.html" %}

{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
添加工时记录
</div>
<div class="card-body">
<form action="/records/" method="post">
<div class="mb-3">
<label for="date" class="form-label">日期</label>
<input type="date" class="form-control" id="date" name="date" value="{{ datetime.now().strftime('%Y-%m-%d') }}" required>
</div>

<div class="row mb-3">
<div class="col">
<label for="start_time" class="form-label">上班时间</label>
<input type="time" class="form-control" id="start_time" name="start_time" required>
</div>
<div class="col">
<label for="end_time" class="form-label">下班时间</label>
<input type="time" class="form-control" id="end_time" name="end_time" required>
</div>
</div>

<div class="mb-3">
<button type="button" class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#breakTimeSettings">
设置休息时间 (默认: 午休 12:00-13:30, 晚餐 17:30-18:00)
</button>

<div class="collapse" id="breakTimeSettings">
<div class="card card-body">
<div class="row mb-3">
<div class="col">
<label for="lunch_break_start" class="form-label">午休开始</label>
<input type="time" class="form-control" id="lunch_break_start" name="lunch_break_start" value="12:00">
</div>
<div class="col">
<label for="lunch_break_end" class="form-label">午休结束</label>
<input type="time" class="form-control" id="lunch_break_end" name="lunch_break_end" value="13:30">
</div>
</div>

<div class="row mb-3">
<div class="col">
<label for="dinner_break_start" class="form-label">晚餐开始</label>
<input type="time" class="form-control" id="dinner_break_start" name="dinner_break_start" value="17:30">
</div>
<div class="col">
<label for="dinner_break_end" class="form-label">晚餐结束</label>
<input type="time" class="form-control" id="dinner_break_end" name="dinner_break_end" value="18:00">
</div>
</div>
</div>
</div>
</div>

<button type="submit" class="btn btn-primary">提交记录</button>
</form>
</div>
</div>
</div>

<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
月度统计
</div>
<div class="card-body">
<form id="statsForm" method="get" action="/">
<div class="row mb-3">
<div class="col">
<label for="year" class="form-label">年份</label>
<select class="form-select" id="year" name="year" onchange="document.getElementById('statsForm').submit()">
{% for y in years %}
<option value="{{ y }}" {% if y == current_year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<div class="col">
<label for="month" class="form-label">月份</label>
<select class="form-select" id="month" name="month" onchange="document.getElementById('statsForm').submit()">
{% for m_num, m_name in months %}
<option value="{{ m_num }}" {% if m_num == current_month %}selected{% endif %}>{{ m_name }}</option>
{% endfor %}
</select>
</div>
</div>

<div class="mb-3">
<label for="expected_daily_hours" class="form-label">期望每日工时</label>
<input type="number" class="form-control" id="expected_daily_hours" name="expected_daily_hours"
value="{{ expected_daily_hours }}" step="0.5" min="0" max="24"
onchange="document.getElementById('statsForm').submit()">
</div>
</form>

<!-- 工作日设置表单 -->
<form action="/working-days/" method="post" class="mb-3">
<input type="hidden" name="year" value="{{ current_year }}">
<input type="hidden" name="month" value="{{ current_month }}">
<div class="mb-3">
<label for="working_days" class="form-label">当月工作日总数</label>
<div class="input-group">
<input type="number" class="form-control" id="working_days" name="working_days"
value="{{ working_days }}" step="1" min="0" max="31">
<button class="btn btn-outline-secondary" type="submit">更新</button>
</div>
</div>
</form>

<!-- 请假工时表单 -->
<form action="/leave/" method="post" class="mb-3">
<input type="hidden" name="year" value="{{ current_year }}">
<input type="hidden" name="month" value="{{ current_month }}">
<div class="mb-3">
<label for="leave_hours" class="form-label">请假工时</label>
<div class="input-group">
<input type="number" class="form-control" id="leave_hours" name="leave_hours"
value="{{ leave_hours }}" step="0.5" min="0">
<button class="btn btn-outline-secondary" type="submit">更新</button>
</div>
</div>
</form>

<div class="mt-4">
<h5>{{ current_year }}年{{ month_name }}统计</h5>
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center">
当月工作日
<span class="badge bg-info rounded-pill">{{ stats.working_days_in_month }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
已记录工作天数
<span class="badge bg-primary rounded-pill">{{ stats.total_days }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
总工时
<span class="badge bg-primary rounded-pill">{{ stats.total_hours }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
平均每日工时
<span class="badge bg-primary rounded-pill">{{ stats.average_hours }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
请假工时
<span class="badge bg-warning rounded-pill">{{ stats.leave_hours }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
月度目标工时
<span class="badge bg-info rounded-pill">{{ stats.total_expected_hours }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
缺少工时
<span class="badge {% if stats.missing_hours > 0 %}bg-danger{% else %}bg-success{% endif %} rounded-pill">
{{ stats.missing_hours }}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>

<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>工时记录列表 ({{ current_year }}年{{ month_name }})</span>
<button id="batchDeleteBtn" class="btn btn-danger btn-sm" style="display: none;" onclick="deleteSelectedRecords()">
批量删除
</button>
</div>
<div class="card-body">
{% if records %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
</th>
<th>日期</th>
<th>上班时间</th>
<th>下班时间</th>
<th>午休时间</th>
<th>晚餐时间</th>
<th>工时</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for record in records %}
<tr id="record-row-{{ record.id }}">
<td>
<input type="checkbox" class="record-checkbox" data-id="{{ record.id }}" onchange="updateBatchDeleteButton()">
</td>
<td>{{ record.date.strftime('%Y-%m-%d') }}</td>
<td>{{ record.start_time.strftime('%H:%M') }}</td>
<td>{{ record.end_time.strftime('%H:%M') }}</td>
<td>{{ record.lunch_break_start.strftime('%H:%M') }}-{{ record.lunch_break_end.strftime('%H:%M') }}</td>
<td>{{ record.dinner_break_start.strftime('%H:%M') }}-{{ record.dinner_break_end.strftime('%H:%M') }}</td>
<td>{{ record.total_hours }}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="editRecord('{{ record.id }}')">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteRecord('{{ record.id }}')">删除</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center">当月暂无工时记录</p>
{% endif %}
</div>
</div>

<!-- 编辑记录模态框 -->
<div class="modal fade" id="editRecordModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑工时记录</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editRecordForm">
<input type="hidden" id="edit_record_id">

<div class="mb-3">
<label for="edit_date" class="form-label">日期</label>
<input type="date" class="form-control" id="edit_date" name="date" required>
</div>

<div class="row mb-3">
<div class="col">
<label for="edit_start_time" class="form-label">上班时间</label>
<input type="time" class="form-control" id="edit_start_time" name="start_time" required>
</div>
<div class="col">
<label for="edit_end_time" class="form-label">下班时间</label>
<input type="time" class="form-control" id="edit_end_time" name="end_time" required>
</div>
</div>

<div class="row mb-3">
<div class="col">
<label for="edit_lunch_break_start" class="form-label">午休开始</label>
<input type="time" class="form-control" id="edit_lunch_break_start" name="lunch_break_start">
</div>
<div class="col">
<label for="edit_lunch_break_end" class="form-label">午休结束</label>
<input type="time" class="form-control" id="edit_lunch_break_end" name="lunch_break_end">
</div>
</div>

<div class="row mb-3">
<div class="col">
<label for="edit_dinner_break_start" class="form-label">晚餐开始</label>
<input type="time" class="form-control" id="edit_dinner_break_start" name="dinner_break_start">
</div>
<div class="col">
<label for="edit_dinner_break_end" class="form-label">晚餐结束</label>
<input type="time" class="form-control" id="edit_dinner_break_end" name="dinner_break_end">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveEditedRecord()">保存</button>
</div>
</div>
</div>
</div>
{% endblock %}

crud.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
from sqlalchemy.orm import Session
from sqlalchemy import extract
from datetime import datetime
from typing import List

from . import models, schemas, utils

def get_work_record(db: Session, record_id: int):
return db.query(models.WorkRecord).filter(models.WorkRecord.id == record_id).first()

def get_work_records(db: Session, skip: int = 0, limit: int = 100):
return db.query(models.WorkRecord).order_by(models.WorkRecord.date.desc()).offset(skip).limit(limit).all()

def get_monthly_records(db: Session, year: int, month: int):
return db.query(models.WorkRecord).filter(
extract('year', models.WorkRecord.date) == year,
extract('month', models.WorkRecord.date) == month
).order_by(models.WorkRecord.date).all()

def create_work_record(db: Session, record: schemas.WorkRecordCreate):
total_hours = utils.calculate_work_hours(
record.start_time,
record.end_time,
record.lunch_break_start,
record.lunch_break_end,
record.dinner_break_start,
record.dinner_break_end
)

db_record = models.WorkRecord(
date=record.date,
start_time=record.start_time,
end_time=record.end_time,
lunch_break_start=record.lunch_break_start,
lunch_break_end=record.lunch_break_end,
dinner_break_start=record.dinner_break_start,
dinner_break_end=record.dinner_break_end,
total_hours=total_hours
)
db.add(db_record)
db.commit()
db.refresh(db_record)
return db_record

def update_work_record(db: Session, record_id: int, record_update: schemas.WorkRecordUpdate):
db_record = get_work_record(db, record_id)
if db_record is None:
return None

update_data = record_update.dict(exclude_unset=True)

# 如果更新了时间相关字段,重新计算工时
time_fields = ['start_time', 'end_time', 'lunch_break_start', 'lunch_break_end', 'dinner_break_start', 'dinner_break_end']
if any(field in update_data for field in time_fields):
# 获取更新后的所有时间字段
start_time = update_data.get('start_time', db_record.start_time)
end_time = update_data.get('end_time', db_record.end_time)
lunch_break_start = update_data.get('lunch_break_start', db_record.lunch_break_start)
lunch_break_end = update_data.get('lunch_break_end', db_record.lunch_break_end)
dinner_break_start = update_data.get('dinner_break_start', db_record.dinner_break_start)
dinner_break_end = update_data.get('dinner_break_end', db_record.dinner_break_end)

# 重新计算工时
total_hours = utils.calculate_work_hours(
start_time, end_time, lunch_break_start, lunch_break_end, dinner_break_start, dinner_break_end
)
update_data['total_hours'] = total_hours

# 更新记录
for key, value in update_data.items():
setattr(db_record, key, value)

db.commit()
db.refresh(db_record)
return db_record

def delete_work_record(db: Session, record_id: int):
db_record = get_work_record(db, record_id)
if db_record is None:
return False
db.delete(db_record)
db.commit()
return True

def delete_multiple_records(db: Session, record_ids: List[int]):
deleted_count = db.query(models.WorkRecord).filter(models.WorkRecord.id.in_(record_ids)).delete(synchronize_session=False)
db.commit()
return deleted_count

# 请假记录相关操作
def get_leave_record(db: Session, year: int, month: int):
return db.query(models.LeaveRecord).filter(
models.LeaveRecord.year == year,
models.LeaveRecord.month == month
).first()

def create_or_update_leave_record(db: Session, leave_record: schemas.LeaveRecordCreate):
db_record = get_leave_record(db, leave_record.year, leave_record.month)

if db_record:
# 更新现有记录
db_record.leave_hours = leave_record.leave_hours
else:
# 创建新记录
db_record = models.LeaveRecord(**leave_record.dict())
db.add(db_record)

db.commit()
db.refresh(db_record)
return db_record

# 工作日设置相关操作
def get_working_day_setting(db: Session, year: int, month: int):
return db.query(models.WorkingDaySetting).filter(
models.WorkingDaySetting.year == year,
models.WorkingDaySetting.month == month
).first()

def create_or_update_working_day_setting(db: Session, setting: schemas.WorkingDaySettingCreate):
db_setting = get_working_day_setting(db, setting.year, setting.month)

if db_setting:
# 更新现有设置
db_setting.working_days = setting.working_days
else:
# 创建新设置
db_setting = models.WorkingDaySetting(**setting.dict())
db.add(db_setting)

db.commit()
db.refresh(db_setting)
return db_setting

database.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

SQLALCHEMY_DATABASE_URL = "sqlite:///./work_time.db"

engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()

# 依赖项
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
import os
import sys

# 获取应用程序的根目录
if getattr(sys, 'frozen', False):
# 如果是打包后的应用
application_path = os.path.dirname(sys.executable)
else:
# 如果是开发环境
application_path = os.path.dirname(os.path.abspath(__file__))

from fastapi import FastAPI, Depends, HTTPException, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, date, time
import calendar

from . import crud, models, schemas, utils
from .database import engine, get_db

models.Base.metadata.create_all(bind=engine)

app = FastAPI(title="工时记录系统")

templates_path = os.path.join(application_path, "templates")
static_path = os.path.join(application_path, "static")

# 挂载静态文件
app.mount("/static", StaticFiles(directory=static_path), name="static")

# 设置模板
templates = Jinja2Templates(directory=templates_path)
# 添加 datetime 到 Jinja2 全局环境
templates.env.globals["datetime"] = datetime

@app.get("/", response_class=HTMLResponse)
def read_root(
request: Request,
db: Session = Depends(get_db),
year: Optional[int] = None,
month: Optional[int] = None,
expected_daily_hours: float = 8.0,
leave_hours: float = 0.0,
working_days: Optional[int] = None
):
# 如果未指定年月,使用当前年月
if not year or not month:
today = datetime.now()
year = today.year
month = today.month

# 获取当月记录
records = crud.get_monthly_records(db, year, month)

# 获取请假记录
leave_record = crud.get_leave_record(db, year, month)
if leave_record:
leave_hours = leave_record.leave_hours

# 获取工作日设置
working_day_setting = crud.get_working_day_setting(db, year, month)

# 如果没有设置工作日或者请求中指定了工作日,使用请求中的值或计算默认值
if working_day_setting is None or working_days is not None:
if working_days is None:
# 计算默认工作日
working_days = utils.get_default_working_days_in_month(year, month)

# 创建或更新工作日设置
working_day_setting = crud.create_or_update_working_day_setting(
db,
schemas.WorkingDaySettingCreate(
year=year,
month=month,
working_days=working_days
)
)

# 使用设置的工作日数量
working_days_in_month = working_day_setting.working_days

# 计算统计数据
stats = utils.calculate_monthly_stats(records, expected_daily_hours, year, month, working_days_in_month, leave_hours)

# 获取月份名称和年份选项
month_name = calendar.month_name[month]
years = list(range(datetime.now().year - 5, datetime.now().year + 2))
months = [(i, calendar.month_name[i]) for i in range(1, 13)]

return templates.TemplateResponse(
"index.html",
{
"request": request,
"records": records,
"stats": stats,
"current_year": year,
"current_month": month,
"month_name": month_name,
"years": years,
"months": months,
"expected_daily_hours": expected_daily_hours,
"leave_hours": leave_hours,
"working_days": working_days_in_month
}
)

@app.post("/records/", response_model=schemas.WorkRecord)
def create_record(
date: str = Form(...),
start_time: str = Form(...),
end_time: str = Form(...),
lunch_break_start: str = Form("12:00"),
lunch_break_end: str = Form("13:30"),
dinner_break_start: str = Form("17:30"),
dinner_break_end: str = Form("18:00"),
db: Session = Depends(get_db)
):
# 转换日期和时间字符串为Python对象
record_date = datetime.strptime(date, "%Y-%m-%d").date()
record_start_time = datetime.strptime(start_time, "%H:%M").time()
record_end_time = datetime.strptime(end_time, "%H:%M").time()
record_lunch_start = datetime.strptime(lunch_break_start, "%H:%M").time()
record_lunch_end = datetime.strptime(lunch_break_end, "%H:%M").time()
record_dinner_start = datetime.strptime(dinner_break_start, "%H:%M").time()
record_dinner_end = datetime.strptime(dinner_break_end, "%H:%M").time()

record = schemas.WorkRecordCreate(
date=record_date,
start_time=record_start_time,
end_time=record_end_time,
lunch_break_start=record_lunch_start,
lunch_break_end=record_lunch_end,
dinner_break_start=record_dinner_start,
dinner_break_end=record_dinner_end
)

db_record = crud.create_work_record(db=db, record=record)

# 重定向回主页
year = record_date.year
month = record_date.month
return RedirectResponse(url=f"/?year={year}&month={month}", status_code=303)

@app.get("/records/{record_id}", response_model=schemas.WorkRecord)
def read_record(record_id: int, db: Session = Depends(get_db)):
db_record = crud.get_work_record(db, record_id=record_id)
if db_record is None:
raise HTTPException(status_code=404, detail="记录未找到")
return db_record

@app.put("/records/{record_id}", response_model=schemas.WorkRecord)
async def update_record(record_id: int, record: schemas.WorkRecordUpdate, db: Session = Depends(get_db)):
db_record = crud.update_work_record(db, record_id=record_id, record_update=record)
if db_record is None:
raise HTTPException(status_code=404, detail="记录未找到")
return db_record

@app.delete("/records/{record_id}")
def delete_record(record_id: int, db: Session = Depends(get_db)):
success = crud.delete_work_record(db, record_id=record_id)
if not success:
raise HTTPException(status_code=404, detail="记录未找到")
return {"detail": "记录已删除"}

@app.post("/records/delete-multiple/")
def delete_multiple_records(records: schemas.DeleteRecords, db: Session = Depends(get_db)):
deleted_count = crud.delete_multiple_records(db, records.record_ids)
return {"detail": f"已删除 {deleted_count} 条记录"}

@app.post("/leave/")
def create_or_update_leave(
year: int = Form(...),
month: int = Form(...),
leave_hours: float = Form(...),
db: Session = Depends(get_db)
):
leave_record = schemas.LeaveRecordCreate(
year=year,
month=month,
leave_hours=leave_hours
)

crud.create_or_update_leave_record(db, leave_record)

# 重定向回主页
return RedirectResponse(url=f"/?year={year}&month={month}&leave_hours={leave_hours}", status_code=303)

@app.post("/working-days/")
def create_or_update_working_days(
year: int = Form(...),
month: int = Form(...),
working_days: int = Form(...),
db: Session = Depends(get_db)
):
setting = schemas.WorkingDaySettingCreate(
year=year,
month=month,
working_days=working_days
)

crud.create_or_update_working_day_setting(db, setting)

# 重定向回主页
return RedirectResponse(url=f"/?year={year}&month={month}&working_days={working_days}", status_code=303)

@app.get("/api/records/", response_model=List[schemas.WorkRecord])
def list_records(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
records = crud.get_work_records(db, skip=skip, limit=limit)
return records

@app.get("/api/stats/")
def get_stats(
year: int = datetime.now().year,
month: int = datetime.now().month,
expected_daily_hours: float = 8.0,
leave_hours: float = 0.0,
db: Session = Depends(get_db)
):
records = crud.get_monthly_records(db, year, month)

# 获取请假记录
leave_record = crud.get_leave_record(db, year, month)
if leave_record:
leave_hours = leave_record.leave_hours

# 获取工作日设置
working_day_setting = crud.get_working_day_setting(db, year, month)
if working_day_setting:
working_days = working_day_setting.working_days
else:
working_days = utils.get_default_working_days_in_month(year, month)

stats = utils.calculate_monthly_stats(records, expected_daily_hours, year, month, working_days, leave_hours)
return stats

models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from sqlalchemy import Column, Integer, String, Date, Time, Float
from sqlalchemy.sql.sqltypes import Boolean

from .database import Base

class WorkRecord(Base):
__tablename__ = "work_records"

id = Column(Integer, primary_key=True, index=True)
date = Column(Date, index=True)
start_time = Column(Time)
end_time = Column(Time)
lunch_break_start = Column(Time)
lunch_break_end = Column(Time)
dinner_break_start = Column(Time)
dinner_break_end = Column(Time)
total_hours = Column(Float)

class LeaveRecord(Base):
__tablename__ = "leave_records"

id = Column(Integer, primary_key=True, index=True)
year = Column(Integer, index=True)
month = Column(Integer, index=True)
leave_hours = Column(Float, default=0.0)

class WorkingDaySetting(Base):
__tablename__ = "working_day_settings"

id = Column(Integer, primary_key=True, index=True)
year = Column(Integer, index=True)
month = Column(Integer, index=True)
working_days = Column(Integer, default=0)

schemas.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from datetime import date, time
from typing import List, Optional
from pydantic import BaseModel

class WorkRecordBase(BaseModel):
date: date
start_time: time
end_time: time
lunch_break_start: Optional[time] = time(12, 0)
lunch_break_end: Optional[time] = time(13, 30)
dinner_break_start: Optional[time] = time(17, 30)
dinner_break_end: Optional[time] = time(18, 0)

class WorkRecordCreate(WorkRecordBase):
pass

class WorkRecord(WorkRecordBase):
id: int
total_hours: float

class Config:
orm_mode = True

class WorkRecordUpdate(BaseModel):
date: Optional[date] = None
start_time: Optional[time] = None
end_time: Optional[time] = None
lunch_break_start: Optional[time] = None
lunch_break_end: Optional[time] = None
dinner_break_start: Optional[time] = None
dinner_break_end: Optional[time] = None

class DeleteRecords(BaseModel):
record_ids: List[int]

class LeaveRecordBase(BaseModel):
year: int
month: int
leave_hours: float

class LeaveRecordCreate(LeaveRecordBase):
pass

class LeaveRecord(LeaveRecordBase):
id: int

class Config:
orm_mode = True

class WorkingDaySettingBase(BaseModel):
year: int
month: int
working_days: int

class WorkingDaySettingCreate(WorkingDaySettingBase):
pass

class WorkingDaySetting(WorkingDaySettingBase):
id: int

class Config:
orm_mode = True

class WorkTimeStats(BaseModel):
average_hours: float
missing_hours: float
expected_daily_hours: float
total_days: int
total_hours: float
working_days_in_month: int
total_expected_hours: float
leave_hours: float

utils.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
from datetime import datetime, time, timedelta, date
import calendar

def calculate_work_hours(start_time, end_time, lunch_break_start, lunch_break_end, dinner_break_start, dinner_break_end):
"""计算工作时间(小时),排除午餐和晚餐时间"""

# 将时间转换为datetime对象以便计算
today = datetime.now().date()
start_dt = datetime.combine(today, start_time)
end_dt = datetime.combine(today, end_time)

# 如果结束时间早于开始时间,假设跨天工作
if end_dt < start_dt:
end_dt += timedelta(days=1)

# 计算总时间(分钟)
total_minutes = (end_dt - start_dt).total_seconds() / 60

# 减去午餐时间
lunch_start_dt = datetime.combine(today, lunch_break_start)
lunch_end_dt = datetime.combine(today, lunch_break_end)

# 检查午餐时间是否在工作时间范围内
if start_dt <= lunch_start_dt and end_dt >= lunch_end_dt:
lunch_minutes = (lunch_end_dt - lunch_start_dt).total_seconds() / 60
total_minutes -= lunch_minutes
elif start_dt <= lunch_end_dt and end_dt >= lunch_end_dt and start_dt >= lunch_start_dt:
lunch_minutes = (lunch_end_dt - start_dt).total_seconds() / 60
total_minutes -= lunch_minutes
elif start_dt <= lunch_start_dt and end_dt >= lunch_start_dt and end_dt <= lunch_end_dt:
lunch_minutes = (end_dt - lunch_start_dt).total_seconds() / 60
total_minutes -= lunch_minutes
elif start_dt >= lunch_start_dt and end_dt <= lunch_end_dt:
# 工作时间完全在午餐时间内
total_minutes = 0

# 减去晚餐时间(特殊规则:18:00后开始计算工时)
dinner_start_dt = datetime.combine(today, dinner_break_start)
dinner_end_dt = datetime.combine(today, dinner_break_end)

# 检查晚餐时间是否在工作时间范围内
if start_dt <= dinner_start_dt and end_dt >= dinner_end_dt:
dinner_minutes = (dinner_end_dt - dinner_start_dt).total_seconds() / 60
total_minutes -= dinner_minutes
elif start_dt >= dinner_start_dt and start_dt < dinner_end_dt and end_dt >= dinner_end_dt:
dinner_minutes = (dinner_end_dt - start_dt).total_seconds() / 60
total_minutes -= dinner_minutes
elif start_dt <= dinner_start_dt and end_dt > dinner_start_dt and end_dt <= dinner_end_dt:
dinner_minutes = (end_dt - dinner_start_dt).total_seconds() / 60
total_minutes -= dinner_minutes
elif start_dt >= dinner_start_dt and end_dt <= dinner_end_dt:
# 工作时间完全在晚餐时间内
total_minutes = 0

# 转换为小时
total_hours = total_minutes / 60

return round(total_hours, 2)

def get_default_working_days_in_month(year, month):
"""计算指定月份的默认工作日数量(周一至周五)"""
cal = calendar.monthcalendar(year, month)
working_days = 0

for week in cal:
# 周一至周五(0是周一,4是周五)
for day_index in range(0, 5):
if week[day_index] != 0: # 0表示该天不在当月
working_days += 1

return working_days

def calculate_monthly_stats(records, expected_daily_hours, year, month, working_days_in_month, leave_hours=0):
"""计算月度统计数据"""
# 计算当月应工作总时数
total_expected_hours = working_days_in_month * expected_daily_hours - leave_hours

if not records:
return {
"average_hours": 0,
"missing_hours": round(total_expected_hours, 2),
"expected_daily_hours": expected_daily_hours,
"total_days": 0,
"total_hours": 0,
"working_days_in_month": working_days_in_month,
"total_expected_hours": round(total_expected_hours, 2),
"leave_hours": leave_hours
}

# 计算总工时和天数
total_hours = sum(record.total_hours for record in records)
total_days = len(records)

# 计算平均工时
average_hours = total_hours / total_days if total_days > 0 else 0

# 计算缺少的工时
missing_hours = max(0, total_expected_hours - total_hours)

return {
"average_hours": round(average_hours, 2),
"missing_hours": round(missing_hours, 2),
"expected_daily_hours": expected_daily_hours,
"total_days": total_days,
"total_hours": round(total_hours, 2),
"working_days_in_month": working_days_in_month,
"total_expected_hours": round(total_expected_hours, 2),
"leave_hours": leave_hours
}

main_exe.py

1
2
3
4
5
import uvicorn
from app.main import app

if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)

requirements.txt

1
2
3
4
5
6
7
fastapi
uvicorn
sqlalchemy
pydantic
jinja2
python-multipart
pyinstaller

打包为可执行文件

spec

最后生成可执行文件在 dist 目录,再将 static 和 templates 目录放在同级下。

1
2
pyi-makespec main_exe.py --name work_time_tracker
pyinstaller work_time_tracker.spec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(
['main_exe.py'],
pathex=[],
binaries=[],
datas=[
('templates', 'templates'), # 修改为app下的templates目录
('static', 'static'), # 修改为app下的static目录
],
hiddenimports=[
'uvicorn.logging',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'uvicorn.lifespan.off',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='work_time_tracker',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)