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