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

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

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

@@ -0,0 +1,373 @@
// 公共工具函数和API调用
// API 基础 URL
const API_BASE_URL = '';
// 通用 API 调用函数
async function apiCall(url, options = {}) {
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
return data;
} catch (error) {
console.error('API调用失败:', error);
throw error;
}
}
// GET 请求
async function apiGet(url) {
return apiCall(url, { method: 'GET' });
}
// POST 请求
async function apiPost(url, data) {
return apiCall(url, {
method: 'POST',
body: JSON.stringify(data)
});
}
// PUT 请求
async function apiPut(url, data) {
return apiCall(url, {
method: 'PUT',
body: JSON.stringify(data)
});
}
// DELETE 请求
async function apiDelete(url) {
return apiCall(url, { method: 'DELETE' });
}
// 显示成功消息
function showSuccess(message) {
showNotification(message, 'success');
}
// 显示错误消息
function showError(message) {
showNotification(message, 'error');
}
// 显示通知
function showNotification(message, type = 'info') {
// 创建通知元素
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = `
<span>${message}</span>
<button onclick="this.parentElement.remove()">&times;</button>
`;
// 添加到页面
document.body.appendChild(notification);
// 自动隐藏
setTimeout(() => {
if (notification.parentElement) {
notification.remove();
}
}, 5000);
}
// 格式化日期
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN');
}
// 格式化日期时间
function formatDateTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN');
}
// 获取今天的日期字符串
function getTodayString() {
const today = new Date();
return today.toISOString().split('T')[0];
}
// 获取本周的开始和结束日期
function getThisWeekRange() {
const today = new Date();
const dayOfWeek = today.getDay();
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - dayOfWeek + 1); // 周一
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6); // 周日
return {
start: startOfWeek.toISOString().split('T')[0],
end: endOfWeek.toISOString().split('T')[0]
};
}
// 模态框控制
function showModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('show');
modal.style.display = 'flex';
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('show');
modal.style.display = 'none';
}
}
// 表单重置
function resetForm(formId) {
const form = document.getElementById(formId);
if (form) {
form.reset();
}
}
// 获取表单数据
function getFormData(formId) {
const form = document.getElementById(formId);
if (!form) return {};
const formData = new FormData(form);
const data = {};
for (let [key, value] of formData.entries()) {
data[key] = value;
}
return data;
}
// 填充表单数据
function fillForm(formId, data) {
const form = document.getElementById(formId);
if (!form) return;
Object.keys(data).forEach(key => {
const field = form.querySelector(`[name="${key}"]`);
if (field) {
field.value = data[key] || '';
}
});
}
// 工时格式化函数
function formatHours(hours) {
if (!hours || hours === '-' || hours === '0:00') {
return hours || '-';
}
// 如果包含冒号,直接返回
if (hours.includes(':')) {
return hours;
}
// 如果是小数格式,转换为时:分格式
const decimal = parseFloat(hours);
if (!isNaN(decimal)) {
const h = Math.floor(decimal);
const m = Math.round((decimal - h) * 60);
return `${h}:${m.toString().padStart(2, '0')}`;
}
return hours;
}
// 工时转换为小数
function hoursToDecimal(hours) {
if (!hours || hours === '-') return 0;
if (hours.includes(':')) {
const [h, m] = hours.split(':').map(Number);
return h + (m || 0) / 60;
}
return parseFloat(hours) || 0;
}
// 小数转换为工时格式
function decimalToHours(decimal) {
if (decimal === 0) return '0:00';
const hours = Math.floor(decimal);
const minutes = Math.round((decimal - hours) * 60);
return `${hours}:${minutes.toString().padStart(2, '0')}`;
}
// 计算工时
function calculateHours(startTime, endTime) {
if (!startTime || !endTime || startTime === '-' || endTime === '-') {
return '-';
}
try {
const [startH, startM] = startTime.split(':').map(Number);
const [endH, endM] = endTime.split(':').map(Number);
let startMinutes = startH * 60 + startM;
let endMinutes = endH * 60 + endM;
// 处理跨日情况
if (endMinutes < startMinutes) {
endMinutes += 24 * 60;
}
const diffMinutes = endMinutes - startMinutes;
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
return `${hours}:${minutes.toString().padStart(2, '0')}`;
} catch (error) {
console.error('计算工时失败:', error);
return '0:00';
}
}
// 星期中文显示
function getDayOfWeekChinese(dateString) {
const date = new Date(dateString);
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return days[date.getDay()];
}
// 判断是否为今天
function isToday(dateString) {
const today = new Date().toISOString().split('T')[0];
return dateString === today;
}
// 文件下载
function downloadFile(content, filename, type = 'text/plain') {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// CSV 生成
function generateCSV(data, headers) {
const csvContent = [
headers.join(','),
...data.map(row => headers.map(header => {
const value = row[header] || '';
// 如果值包含逗号、引号或换行符,需要用引号包围
if (value.toString().includes(',') || value.toString().includes('"') || value.toString().includes('\n')) {
return `"${value.toString().replace(/"/g, '""')}"`;
}
return value;
}).join(','))
].join('\n');
return csvContent;
}
// 页面加载完成后的初始化
document.addEventListener('DOMContentLoaded', function() {
// 添加点击外部关闭模态框的功能
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal')) {
e.target.classList.remove('show');
e.target.style.display = 'none';
}
});
// 添加ESC键关闭模态框的功能
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const openModal = document.querySelector('.modal.show');
if (openModal) {
openModal.classList.remove('show');
openModal.style.display = 'none';
}
}
});
});
// 添加通知样式到头部(如果不存在)
if (!document.querySelector('#notification-styles')) {
const style = document.createElement('style');
style.id = 'notification-styles';
style.textContent = `
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 5px;
color: white;
z-index: 10000;
display: flex;
align-items: center;
gap: 15px;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: slideIn 0.3s ease-out;
}
.notification.success {
background-color: #27ae60;
}
.notification.error {
background-color: #e74c3c;
}
.notification.info {
background-color: #3498db;
}
.notification button {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
document.head.appendChild(style);
}

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

@@ -0,0 +1,210 @@
// 首页面板JavaScript
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
loadDashboardStats();
loadRecentRecords();
});
// 加载仪表板统计数据
async function loadDashboardStats() {
try {
// 并行加载项目和工时统计
const [projectsResponse, recentRecordsResponse] = await Promise.all([
apiGet('/api/projects'),
loadThisWeekHours()
]);
// 更新活跃项目数
const activeProjects = projectsResponse.data.filter(p => p.is_active).length;
updateStatValue('total-projects', activeProjects);
// 更新本周工时
updateStatValue('this-week-hours', recentRecordsResponse.weeklyHours || '0:00');
// 加载本月记录数
const thisMonthCount = await loadThisMonthRecordsCount();
updateStatValue('this-month-records', thisMonthCount);
} catch (error) {
console.error('加载仪表板统计失败:', error);
// 显示默认值
updateStatValue('total-projects', '0');
updateStatValue('this-week-hours', '0:00');
updateStatValue('this-month-records', '0');
}
}
// 更新统计值
function updateStatValue(elementId, value) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = value;
}
}
// 加载本周工时
async function loadThisWeekHours() {
try {
const weekRange = getThisWeekRange();
const url = `/api/timerecords?start_date=${weekRange.start}&end_date=${weekRange.end}`;
const response = await apiGet(url);
// 计算本周总工时
let totalHours = 0;
response.data.forEach(record => {
if (record.hours && record.hours !== '-') {
totalHours += hoursToDecimal(record.hours);
}
});
return {
weeklyHours: decimalToHours(totalHours),
recordCount: response.data.length
};
} catch (error) {
console.error('加载本周工时失败:', error);
return { weeklyHours: '0:00', recordCount: 0 };
}
}
// 加载本月记录数
async function loadThisMonthRecordsCount() {
try {
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const startDate = firstDayOfMonth.toISOString().split('T')[0];
const endDate = lastDayOfMonth.toISOString().split('T')[0];
const url = `/api/timerecords?start_date=${startDate}&end_date=${endDate}`;
const response = await apiGet(url);
return response.data.length;
} catch (error) {
console.error('加载本月记录数失败:', error);
return 0;
}
}
// 加载最近记录
async function loadRecentRecords() {
try {
// 获取最近7天的记录
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 6); // 最近7天
const url = `/api/timerecords?start_date=${startDate.toISOString().split('T')[0]}&end_date=${endDate.toISOString().split('T')[0]}`;
const response = await apiGet(url);
renderRecentRecords(response.data.slice(0, 5)); // 只显示最近5条
} catch (error) {
console.error('加载最近记录失败:', error);
const container = document.getElementById('recent-records-list');
if (container) {
container.innerHTML = '<p class="text-center">加载失败</p>';
}
}
}
// 渲染最近记录
function renderRecentRecords(records) {
const container = document.getElementById('recent-records-list');
if (!container) return;
if (records.length === 0) {
container.innerHTML = '<p class="text-center">暂无最近记录</p>';
return;
}
container.innerHTML = `
<div class="recent-records-table">
<table class="data-table">
<thead>
<tr>
<th>日期</th>
<th>事件</th>
<th>项目</th>
<th>工时</th>
</tr>
</thead>
<tbody>
${records.map(record => {
const projectDisplay = record.project
? (record.project.project_type === 'traditional'
? `${record.project.customer_name} ${record.project.project_code}`
: `${record.project.customer_name} ${record.project.contract_number}`)
: '-';
const rowClass = getRowClass(record);
return `
<tr class="${rowClass}">
<td>
${formatDate(record.date)}
${isToday(record.date) ? '<span class="today-badge">今天</span>' : ''}
</td>
<td>${escapeHtml(record.event_description || '-')}</td>
<td>${escapeHtml(projectDisplay)}</td>
<td>${record.hours || '-'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
// 获取行的CSS类名根据是否为休息日
function getRowClass(record) {
if (record.is_holiday) {
if (record.is_working_on_holiday) {
return 'working-holiday-row'; // 休息日工作
} else {
return 'holiday-row'; // 休息日休息
}
}
return '';
}
// HTML转义函数
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 添加今天徽章样式
if (!document.querySelector('#today-badge-styles')) {
const style = document.createElement('style');
style.id = 'today-badge-styles';
style.textContent = `
.today-badge {
background-color: #3498db;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
margin-left: 5px;
}
.recent-records-table {
overflow-x: auto;
}
.recent-records-table .data-table {
margin-bottom: 0;
}
.recent-records-table .data-table td {
font-size: 13px;
padding: 10px 8px;
}
`;
document.head.appendChild(style);
}

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

@@ -0,0 +1,386 @@
// 项目管理页面JavaScript
let projects = [];
let currentEditingProject = null;
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
loadProjects();
setupEventListeners();
});
// 设置事件监听器
function setupEventListeners() {
// 项目表单提交
const projectForm = document.getElementById('project-form');
if (projectForm) {
projectForm.addEventListener('submit', handleProjectSubmit);
}
}
// 加载项目列表
async function loadProjects() {
try {
const response = await apiGet('/api/projects');
projects = response.data;
renderProjectsTable();
updateProjectStats();
} catch (error) {
showError('加载项目失败: ' + error.message);
console.error('加载项目失败:', error);
}
}
// 渲染项目表格
function renderProjectsTable() {
const tbody = document.getElementById('projects-tbody');
if (!tbody) return;
if (projects.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">暂无项目数据</td></tr>';
return;
}
tbody.innerHTML = projects.map(project => `
<tr>
<td>${escapeHtml(project.project_name)}</td>
<td>
<span class="badge ${project.project_type === 'traditional' ? 'badge-primary' : 'badge-secondary'}">
${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'}
</span>
</td>
<td>${escapeHtml(project.customer_name)}</td>
<td>
${project.project_type === 'traditional'
? escapeHtml(project.project_code)
: escapeHtml(project.contract_number || 'PSI-PROJ')}
</td>
<td>
<span class="badge ${project.is_active ? 'badge-success' : 'badge-danger'}">
${project.is_active ? '活跃' : '禁用'}
</span>
</td>
<td>${formatDate(project.start_date)}</td>
<td>${formatDateTime(project.created_at)}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editProject(${project.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">删除</button>
</td>
</tr>
`).join('');
}
// 更新项目统计
function updateProjectStats() {
const totalCount = projects.length;
const traditionalCount = projects.filter(p => p.project_type === 'traditional').length;
const psiCount = projects.filter(p => p.project_type === 'psi').length;
const totalElement = document.getElementById('total-projects-count');
const traditionalElement = document.getElementById('traditional-projects-count');
const psiElement = document.getElementById('psi-projects-count');
if (totalElement) totalElement.textContent = totalCount;
if (traditionalElement) traditionalElement.textContent = traditionalCount;
if (psiElement) psiElement.textContent = psiCount;
}
// 筛选项目
function filterProjects() {
const typeFilter = document.getElementById('project-type-filter').value;
let filteredProjects = projects;
if (typeFilter) {
filteredProjects = projects.filter(p => p.project_type === typeFilter);
}
renderFilteredProjects(filteredProjects);
}
// 渲染筛选后的项目
function renderFilteredProjects(filteredProjects) {
const tbody = document.getElementById('projects-tbody');
if (!tbody) return;
if (filteredProjects.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">没有符合条件的项目</td></tr>';
return;
}
tbody.innerHTML = filteredProjects.map(project => `
<tr>
<td>${escapeHtml(project.project_name)}</td>
<td>
<span class="badge ${project.project_type === 'traditional' ? 'badge-primary' : 'badge-secondary'}">
${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'}
</span>
</td>
<td>${escapeHtml(project.customer_name)}</td>
<td>
${project.project_type === 'traditional'
? escapeHtml(project.project_code)
: escapeHtml(project.contract_number || 'PSI-PROJ')}
</td>
<td>
<span class="badge ${project.is_active ? 'badge-success' : 'badge-danger'}">
${project.is_active ? '活跃' : '禁用'}
</span>
</td>
<td>${formatDate(project.start_date)}</td>
<td>${formatDateTime(project.created_at)}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editProject(${project.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">删除</button>
</td>
</tr>
`).join('');
}
// 显示创建项目模态框
function showCreateProjectModal() {
currentEditingProject = null;
resetForm('project-form');
document.getElementById('modal-title').textContent = '新建项目';
// 隐藏项目类型特定字段
document.getElementById('traditional-fields').style.display = 'none';
document.getElementById('psi-fields').style.display = 'none';
showModal('create-project-modal');
}
// 项目类型切换
function toggleProjectFields() {
const projectType = document.getElementById('project_type').value;
const traditionalFields = document.getElementById('traditional-fields');
const psiFields = document.getElementById('psi-fields');
const projectCodeField = document.getElementById('project_code');
const contractNumberField = document.getElementById('contract_number');
if (projectType === 'traditional') {
traditionalFields.style.display = 'block';
psiFields.style.display = 'none';
projectCodeField.required = true;
contractNumberField.required = false;
} else if (projectType === 'psi') {
traditionalFields.style.display = 'none';
psiFields.style.display = 'block';
projectCodeField.required = false;
contractNumberField.required = true;
} else {
traditionalFields.style.display = 'none';
psiFields.style.display = 'none';
projectCodeField.required = false;
contractNumberField.required = false;
}
}
// 处理项目表单提交
async function handleProjectSubmit(e) {
e.preventDefault();
const formData = getFormData('project-form');
try {
let response;
if (currentEditingProject) {
// 更新项目
response = await apiPut(`/api/projects/${currentEditingProject.id}`, formData);
} else {
// 创建新项目
response = await apiPost('/api/projects', formData);
}
showSuccess(currentEditingProject ? '项目更新成功' : '项目创建成功');
closeModal('create-project-modal');
loadProjects(); // 重新加载项目列表
} catch (error) {
showError(error.message);
}
}
// 编辑项目
function editProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) {
showError('项目不存在');
return;
}
currentEditingProject = project;
document.getElementById('modal-title').textContent = '编辑项目';
// 填充表单数据
fillForm('project-form', project);
// 设置项目类型并显示对应字段
document.getElementById('project_type').value = project.project_type;
toggleProjectFields();
showModal('create-project-modal');
}
// 删除项目
async function deleteProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) {
showError('项目不存在');
return;
}
if (!confirm(`确定要删除项目"${project.project_name}"吗?`)) {
return;
}
try {
await apiDelete(`/api/projects/${projectId}`);
showSuccess('项目删除成功');
loadProjects(); // 重新加载项目列表
} catch (error) {
showError('删除项目失败: ' + error.message);
}
}
// 显示导入模态框
function showImportModal() {
showModal('import-modal');
}
// 下载模板文件
function downloadTemplate(type) {
let csvContent = '';
if (type === 'traditional') {
csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
Project Alpha,traditional,客户A,01A-DEV,,Alpha开发项目`;
} else if (type === 'psi') {
csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
Samsung项目,psi,Samsung,PSI-PROJ,SC20241201,Samsung客户项目`;
} else if (type === 'mixed') {
csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
Project Beta,traditional,客户B,01B-TEST,,Beta测试项目`;
}
downloadFile(csvContent, `项目模板_${type}.csv`, 'text/csv;charset=utf-8');
}
// 导入项目
async function importProjects() {
const fileInput = document.getElementById('import-file');
const file = fileInput.files[0];
if (!file) {
showError('请选择CSV文件');
return;
}
if (!file.name.endsWith('.csv')) {
showError('请选择CSV文件');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/projects/import', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || '导入失败');
}
// 显示导入结果
showImportResult(result);
closeModal('import-modal');
loadProjects(); // 重新加载项目列表
} catch (error) {
showError('导入项目失败: ' + error.message);
}
}
// 显示导入结果
function showImportResult(result) {
const content = document.getElementById('import-result-content');
let html = `<div class="import-summary">
<h4>导入完成</h4>
<p>成功导入 <strong>${result.created_count}</strong> 个项目</p>
</div>`;
if (result.errors && result.errors.length > 0) {
html += `<div class="import-errors">
<h5>导入错误 (${result.errors.length} 项):</h5>
<ul>`;
result.errors.forEach(error => {
html += `<li>${escapeHtml(error)}</li>`;
});
html += `</ul></div>`;
}
content.innerHTML = html;
showModal('import-result-modal');
}
// HTML转义函数
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 添加徽章样式如果CSS中没有定义
if (!document.querySelector('#badge-styles')) {
const style = document.createElement('style');
style.id = 'badge-styles';
style.textContent = `
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 600;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 4px;
}
.badge-primary {
background-color: #3498db;
color: white;
}
.badge-secondary {
background-color: #6c757d;
color: white;
}
.badge-success {
background-color: #27ae60;
color: white;
}
.badge-danger {
background-color: #e74c3c;
color: white;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
`;
document.head.appendChild(style);
}

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

@@ -0,0 +1,485 @@
// 统计分析页面JavaScript
let cutoffPeriods = [];
let currentStats = null;
let projectHoursChart = null; // 用于存储图表实例
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
loadCutoffPeriods();
setupEventListeners();
});
// 设置事件监听器
function setupEventListeners() {
// 周期表单提交
const periodForm = document.getElementById('period-form');
if (periodForm) {
periodForm.addEventListener('submit', handlePeriodSubmit);
}
}
// 加载Cut-Off周期
async function loadCutoffPeriods() {
try {
const response = await apiGet('/api/statistics/periods');
cutoffPeriods = response.data;
populatePeriodSelect();
} catch (error) {
showError('加载周期列表失败: ' + error.message);
console.error('加载周期列表失败:', error);
}
}
// 填充周期选择框
function populatePeriodSelect() {
const select = document.getElementById('period-select');
if (!select) return;
// 清空现有选项(保留第一个默认选项)
const firstOption = select.querySelector('option');
select.innerHTML = '';
if (firstOption) {
select.appendChild(firstOption);
}
// 添加周期选项
cutoffPeriods.forEach(period => {
const option = document.createElement('option');
option.value = period.id;
option.textContent = `${period.period_name} (${formatDate(period.start_date)} - ${formatDate(period.end_date)})`;
select.appendChild(option);
});
}
// 加载周统计数据
async function loadWeeklyStats() {
const periodId = document.getElementById('period-select').value;
if (!periodId) {
hideStatsDisplay();
return;
}
try {
const response = await apiGet(`/api/statistics/weekly?period_id=${periodId}`);
currentStats = response.data;
displayStats();
} catch (error) {
showError('加载统计数据失败: ' + error.message);
console.error('加载统计数据失败:', error);
}
}
// 加载自定义日期范围统计
async function loadCustomStats() {
const startDate = document.getElementById('custom-start-date').value;
const endDate = document.getElementById('custom-end-date').value;
if (!startDate || !endDate) {
showError('请选择开始和结束日期');
return;
}
if (new Date(startDate) > new Date(endDate)) {
showError('开始日期不能晚于结束日期');
return;
}
try {
const response = await apiGet(`/api/statistics/weekly?start_date=${startDate}&end_date=${endDate}`);
currentStats = response.data;
displayStats();
// 清空周期选择
document.getElementById('period-select').value = '';
} catch (error) {
showError('加载统计数据失败: ' + error.message);
console.error('加载统计数据失败:', error);
}
}
// 显示统计数据
function displayStats() {
if (!currentStats) return;
showStatsDisplay();
updateStatsOverview();
renderDailyDetails();
renderProjectDistribution();
}
// 显示统计界面
function showStatsDisplay() {
document.getElementById('stats-overview').style.display = 'block';
document.getElementById('daily-details').style.display = 'block';
document.getElementById('project-distribution').style.display = 'block';
}
// 隐藏统计界面
function hideStatsDisplay() {
document.getElementById('stats-overview').style.display = 'none';
document.getElementById('daily-details').style.display = 'none';
document.getElementById('project-distribution').style.display = 'none';
}
// 更新统计概览
function updateStatsOverview() {
if (!currentStats) return;
// 更新周期信息
document.getElementById('current-period-name').textContent = currentStats.period.period_name;
document.getElementById('period-date-range').textContent =
`${formatDate(currentStats.period.start_date)} - ${formatDate(currentStats.period.end_date)}`;
// 更新统计数据
document.getElementById('workday-total').textContent = currentStats.workday_total;
document.getElementById('holiday-total').textContent = currentStats.holiday_total;
document.getElementById('weekly-total').textContent = currentStats.weekly_total;
document.getElementById('completion-rate').textContent = `${currentStats.completion_rate}%`;
// 更新工作天数统计
document.getElementById('working-days').textContent = currentStats.working_days;
document.getElementById('holiday-work-days').textContent = currentStats.holiday_work_days;
document.getElementById('rest-days').textContent = currentStats.rest_days;
// 更新完成度颜色
const completionElement = document.getElementById('completion-rate');
completionElement.className = 'stat-value';
if (currentStats.completion_rate >= 100) {
completionElement.style.color = '#27ae60';
} else if (currentStats.completion_rate >= 80) {
completionElement.style.color = '#f39c12';
} else {
completionElement.style.color = '#e74c3c';
}
}
// 渲染每日详情
function renderDailyDetails() {
if (!currentStats || !currentStats.daily_records) return;
const tbody = document.getElementById('daily-stats-tbody');
if (!tbody) return;
tbody.innerHTML = currentStats.daily_records.map(record => {
const rowClass = getRowClass(record);
return `
<tr class="${rowClass}">
<td>${formatDate(record.date)}</td>
<td>${record.day_of_week}</td>
<td>${escapeHtml(record.event)}</td>
<td>${escapeHtml(record.project)}</td>
<td>${record.start_time}</td>
<td>${record.end_time}</td>
<td>${escapeHtml(record.activity_num)}</td>
<td>${record.hours}</td>
</tr>
`;
}).join('');
}
// 渲染项目工时分布图表和表格
function renderProjectDistribution() {
if (!currentStats || !currentStats.project_hours) return;
const projectData = currentStats.project_hours;
const tableBody = document.getElementById('project-hours-tbody');
const ctx = document.getElementById('project-hours-chart').getContext('2d');
if (!ctx || !tableBody) return;
// 清理旧内容
tableBody.innerHTML = '';
if (projectHoursChart) {
projectHoursChart.destroy();
}
if (projectData.length === 0) {
// 在表格中显示无数据信息
tableBody.innerHTML = '<tr><td colspan="3" style="text-align: center;">暂无项目工时数据</td></tr>';
// 清理画布
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.font = "16px Arial";
ctx.fillStyle = "#888";
ctx.textAlign = "center";
ctx.fillText("暂无项目工时数据", ctx.canvas.width / 2, 50);
return;
}
// 填充表格
projectData.forEach(item => {
const row = `
<tr>
<td>${escapeHtml(item.project)}</td>
<td>${item.hours}</td>
<td>${item.percentage}%</td>
</tr>
`;
tableBody.innerHTML += row;
});
// 准备图表数据
const labels = projectData.map(item => item.project);
const data = projectData.map(item => parseFloat(item.hours.replace(':', '.')));
const backgroundColors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40',
'#E7E9ED', '#8DDF3C', '#FFD700', '#B22222', '#4682B4', '#D2B48C'
];
// 渲染图表
projectHoursChart = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
label: '工时',
data: data,
backgroundColor: backgroundColors.slice(0, data.length),
borderColor: '#fff',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
},
title: {
display: false, // 标题可以省略,因为旁边有表格
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.label || '';
if (label) {
label += ': ';
}
if (context.parsed !== null) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((context.parsed / total) * 100).toFixed(2);
label += `${context.raw} 小时 (${percentage}%)`;
}
return label;
}
}
}
}
}
});
}
// 获取行的CSS类名根据是否为休息日
function getRowClass(record) {
if (record.is_holiday) {
if (record.is_working_on_holiday) {
return 'working-holiday-row'; // 休息日工作
} else {
return 'holiday-row'; // 休息日休息
}
}
return '';
}
// 显示创建周期模态框
function showCreatePeriodModal() {
resetForm('period-form');
showModal('create-period-modal');
}
// 计算周期信息
function calculatePeriodInfo() {
const startDate = document.getElementById('start_date').value;
const endDate = document.getElementById('end_date').value;
if (!startDate || !endDate) return;
const start = new Date(startDate);
const end = new Date(endDate);
if (start >= end) {
showError('开始日期必须早于结束日期');
return;
}
// 计算天数和周数
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
const weeks = Math.round(daysDiff / 7);
// 更新周数字段
document.getElementById('weeks').value = weeks;
// 更新目标工时(如果为空)
const targetHoursField = document.getElementById('target_hours');
if (!targetHoursField.value) {
targetHoursField.value = weeks * 40; // 默认每周40小时
}
}
// 应用周期模板
function applyTemplate(templateType) {
const today = new Date();
let startDate, endDate, weeks, targetHours, periodName;
switch (templateType) {
case 'weekly':
// 本周周期
const dayOfWeek = today.getDay();
const startOfWeek = new Date(today);
startOfWeek.setDate(today.getDate() - dayOfWeek + 1); // 周一
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6); // 周日
startDate = startOfWeek.toISOString().split('T')[0];
endDate = endOfWeek.toISOString().split('T')[0];
weeks = 1;
targetHours = 40;
periodName = `${today.getFullYear()}年第${getWeekNumber(today)}`;
break;
case 'biweekly':
// 双周周期
startDate = getTodayString();
const biweekEnd = new Date(today);
biweekEnd.setDate(today.getDate() + 13);
endDate = biweekEnd.toISOString().split('T')[0];
weeks = 2;
targetHours = 80;
periodName = `双周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
break;
case 'four-weeks':
// 4周周期
startDate = getTodayString();
const fourWeeksEnd = new Date(today);
fourWeeksEnd.setDate(today.getDate() + 27);
endDate = fourWeeksEnd.toISOString().split('T')[0];
weeks = 4;
targetHours = 160;
periodName = `4周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
break;
case 'five-weeks':
// 5周周期
startDate = getTodayString();
const fiveWeeksEnd = new Date(today);
fiveWeeksEnd.setDate(today.getDate() + 34);
endDate = fiveWeeksEnd.toISOString().split('T')[0];
weeks = 5;
targetHours = 200;
periodName = `5周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
break;
default:
// 默认行为可以指向一个常用模板例如4周
startDate = getTodayString();
const defaultEnd = new Date(today);
defaultEnd.setDate(today.getDate() + 27);
endDate = defaultEnd.toISOString().split('T')[0];
weeks = 4;
targetHours = 160;
periodName = `4周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
break;
}
// 填充表单
document.getElementById('period_name').value = periodName;
document.getElementById('start_date').value = startDate;
document.getElementById('end_date').value = endDate;
document.getElementById('weeks').value = weeks;
document.getElementById('target_hours').value = targetHours;
}
// 获取周数
function getWeekNumber(date) {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
}
// 处理周期表单提交
async function handlePeriodSubmit(e) {
e.preventDefault();
const formData = getFormData('period-form');
// 验证必填字段
if (!formData.period_name || !formData.start_date || !formData.end_date) {
showError('请填写完整的周期信息');
return;
}
try {
const response = await apiPost('/api/statistics/periods', formData);
showSuccess('Cut-Off周期创建成功');
closeModal('create-period-modal');
loadCutoffPeriods(); // 重新加载周期列表
} catch (error) {
showError(error.message);
}
}
// 管理周期
function managePeriods() {
loadPeriodsTable();
showModal('manage-periods-modal');
}
// 加载周期管理表格
function loadPeriodsTable() {
const tbody = document.getElementById('periods-tbody');
if (!tbody) return;
if (cutoffPeriods.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">暂无周期数据</td></tr>';
return;
}
tbody.innerHTML = cutoffPeriods.map(period => `
<tr>
<td>${escapeHtml(period.period_name)}</td>
<td>${formatDate(period.start_date)}</td>
<td>${formatDate(period.end_date)}</td>
<td>${period.weeks}</td>
<td>${period.target_hours}小时</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deletePeriod(${period.id})">删除</button>
</td>
</tr>
`).join('');
}
// 删除周期
async function deletePeriod(periodId) {
const period = cutoffPeriods.find(p => p.id === periodId);
if (!period) {
showError('周期不存在');
return;
}
if (!confirm(`确定要删除周期"${period.period_name}"吗?`)) {
return;
}
try {
await apiDelete(`/api/statistics/periods/${periodId}`);
showSuccess('周期删除成功');
loadCutoffPeriods(); // 重新加载周期列表
loadPeriodsTable(); // 更新管理表格
} catch (error) {
showError('删除周期失败: ' + error.message);
}
}
// HTML转义函数
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

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

@@ -0,0 +1,347 @@
// 工时记录页面JavaScript
let timeRecords = [];
let projects = [];
let currentEditingRecord = null;
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
initializePage();
setupEventListeners();
});
// 初始化页面
async function initializePage() {
await Promise.all([
loadProjects(),
loadTimeRecords()
]);
// 设置默认筛选日期为本周
const weekRange = getThisWeekRange();
document.getElementById('filter-start-date').value = weekRange.start;
document.getElementById('filter-end-date').value = weekRange.end;
// 设置默认记录日期为今天
document.getElementById('record_date').value = getTodayString();
}
// 设置事件监听器
function setupEventListeners() {
// 工时记录表单提交
const recordForm = document.getElementById('timerecord-form');
if (recordForm) {
recordForm.addEventListener('submit', handleRecordSubmit);
}
// 时间输入变化时自动计算工时
const startTimeInput = document.getElementById('start_time');
const endTimeInput = document.getElementById('end_time');
if (startTimeInput) {
startTimeInput.addEventListener('change', calculateHours);
}
if (endTimeInput) {
endTimeInput.addEventListener('change', calculateHours);
}
}
// 加载项目列表
async function loadProjects() {
try {
const response = await apiGet('/api/projects');
projects = response.data;
populateProjectSelect();
} catch (error) {
showError('加载项目失败: ' + error.message);
console.error('加载项目失败:', error);
}
}
// 填充项目选择框
function populateProjectSelect() {
const selects = ['project_id', 'filter-project'];
selects.forEach(selectId => {
const select = document.getElementById(selectId);
if (!select) return;
// 清空现有选项(保留第一个默认选项)
const firstOption = select.querySelector('option');
select.innerHTML = '';
if (firstOption) {
select.appendChild(firstOption);
}
// 添加项目选项
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name;
select.appendChild(option);
});
});
}
// 加载工时记录
async function loadTimeRecords() {
try {
const params = new URLSearchParams();
const startDate = document.getElementById('filter-start-date').value;
const endDate = document.getElementById('filter-end-date').value;
const projectId = document.getElementById('filter-project').value;
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (projectId) params.append('project_id', projectId);
const url = `/api/timerecords${params.toString() ? '?' + params.toString() : ''}`;
const response = await apiGet(url);
timeRecords = response.data;
renderTimeRecordsTable();
} catch (error) {
showError('加载工时记录失败: ' + error.message);
console.error('加载工时记录失败:', error);
}
}
// 渲染工时记录表格
function renderTimeRecordsTable() {
const tbody = document.getElementById('timerecords-tbody');
if (!tbody) return;
if (timeRecords.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">暂无工时记录</td></tr>';
return;
}
tbody.innerHTML = timeRecords.map(record => {
const projectDisplay = record.project ? record.project.project_name : '-';
const rowClass = getRowClass(record);
return `
<tr class="${rowClass}">
<td>${formatDate(record.date)}</td>
<td>${record.day_of_week || getDayOfWeekChinese(record.date)}</td>
<td>${escapeHtml(record.event_description || '-')}</td>
<td>${escapeHtml(projectDisplay)}</td>
<td>${record.start_time || '-'}</td>
<td>${record.end_time || '-'}</td>
<td>${escapeHtml(record.activity_num || '-')}</td>
<td>${record.hours || '-'}</td>
<td>
<button class="btn btn-sm btn-outline" onclick="editRecord(${record.id})">编辑</button>
<button class="btn btn-sm btn-danger" onclick="deleteRecord(${record.id})">删除</button>
</td>
</tr>
`;
}).join('');
}
// 获取行的CSS类名根据是否为休息日
function getRowClass(record) {
if (record.is_holiday) {
if (record.is_working_on_holiday) {
return 'working-holiday-row'; // 休息日工作
} else {
return 'holiday-row'; // 休息日休息
}
}
return '';
}
// 重置筛选条件
function resetFilters() {
document.getElementById('filter-start-date').value = '';
document.getElementById('filter-end-date').value = '';
document.getElementById('filter-project').value = '';
loadTimeRecords();
}
// 显示创建记录模态框
function showCreateRecordModal() {
currentEditingRecord = null;
resetForm('timerecord-form');
document.getElementById('timerecord-modal-title').textContent = '新建工时记录';
// 设置默认日期为今天
document.getElementById('record_date').value = getTodayString();
// 设置默认时间
document.getElementById('start_time').value = '09:00';
document.getElementById('end_time').value = '17:00';
// 自动计算工时
updateHoursInput();
// 隐藏休息日信息和警告
document.getElementById('holiday-info').style.display = 'none';
document.getElementById('holiday-warning').style.display = 'none';
showModal('timerecord-modal');
// 检查今天是否为休息日
checkHoliday();
}
// 检查休息日
async function checkHoliday() {
const dateInput = document.getElementById('record_date');
const date = dateInput.value;
if (!date) return;
try {
const response = await apiGet(`/api/timerecords/check_holiday/${date}`);
const holidayInfo = response.data;
updateHolidayInfo(holidayInfo);
} catch (error) {
console.error('检查休息日失败:', error);
// 如果API调用失败使用本地判断
const dayOfWeek = new Date(date).getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
updateHolidayInfo({
is_holiday: isWeekend,
holiday_type: isWeekend ? 'weekend' : null,
holiday_name: null,
day_of_week: getDayOfWeekChinese(date)
});
}
}
// 更新休息日信息显示
function updateHolidayInfo(holidayInfo) {
const holidayInfoDiv = document.getElementById('holiday-info');
const holidayBadge = document.getElementById('holiday-badge');
const holidayText = document.getElementById('holiday-text');
const holidayWarning = document.getElementById('holiday-warning');
if (holidayInfo.is_holiday) {
// 显示休息日信息
holidayInfoDiv.style.display = 'flex';
holidayWarning.style.display = 'block';
// 设置徽章样式和文本
if (holidayInfo.holiday_type === 'weekend') {
holidayBadge.className = 'badge weekend';
holidayBadge.textContent = '周末';
} else if (holidayInfo.holiday_type === 'national_holiday') {
holidayBadge.className = 'badge national-holiday';
holidayBadge.textContent = '节假日';
} else {
holidayBadge.className = 'badge weekend';
holidayBadge.textContent = '休息日';
}
holidayText.textContent = holidayInfo.holiday_name || `${holidayInfo.day_of_week} 休息日`;
} else {
// 隐藏休息日信息
holidayInfoDiv.style.display = 'none';
holidayWarning.style.display = 'none';
}
}
// 更新工时输入框
function updateHoursInput() {
const startTime = document.getElementById('start_time').value;
const endTime = document.getElementById('end_time').value;
const hoursField = document.getElementById('hours');
if (startTime && endTime) {
const calculated = calculateHours(startTime, endTime); // 调用 common.js 中的全局函数
hoursField.value = calculated;
} else {
hoursField.value = '';
}
}
// 处理记录表单提交
async function handleRecordSubmit(e) {
e.preventDefault();
const formData = getFormData('timerecord-form');
// 验证必填字段
if (!formData.date) {
showError('请选择日期');
return;
}
try {
let response;
if (currentEditingRecord) {
// 更新记录
response = await apiPut(`/api/timerecords/${currentEditingRecord.id}`, formData);
} else {
// 创建新记录
response = await apiPost('/api/timerecords', formData);
}
showSuccess(currentEditingRecord ? '工时记录更新成功' : '工时记录创建成功');
closeModal('timerecord-modal');
location.reload(); // 刷新页面以显示最新数据
} catch (error) {
showError(error.message);
}
}
// 编辑记录
function editRecord(recordId) {
const record = timeRecords.find(r => r.id === recordId);
if (!record) {
showError('记录不存在');
return;
}
currentEditingRecord = record;
document.getElementById('timerecord-modal-title').textContent = '编辑工时记录';
// 填充表单数据
fillForm('timerecord-form', {
date: record.date,
event_description: record.event_description || '',
project_id: record.project_id || '',
start_time: record.start_time || '',
end_time: record.end_time || '',
activity_num: record.activity_num || '',
hours: record.hours || ''
});
showModal('timerecord-modal');
// 检查是否为休息日
checkHoliday();
}
// 删除记录
async function deleteRecord(recordId) {
const record = timeRecords.find(r => r.id === recordId);
if (!record) {
showError('记录不存在');
return;
}
if (!confirm(`确定要删除这条工时记录吗?`)) {
return;
}
try {
await apiDelete(`/api/timerecords/${recordId}`);
showSuccess('工时记录删除成功');
location.reload(); // 刷新页面以显示最新数据
} catch (error) {
showError('删除工时记录失败: ' + error.message);
}
}
// HTML转义函数
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}