refactor(api): 重构数据库访问为SQLAlchemy绑定的session
- 统一移除手动创建的数据库session,统一使用models模块中的db.session - 修正项目创建接口,增加开始和结束日期的格式验证与处理 - 更新导入项目接口,使用枚举类型校验项目类型并优化异常处理 - 更新统计接口,避免多次查询假期数据,优化日期字符串处理 - 删除回滚前多余的session关闭调用,改为使用db.session.rollback() - app.py中重构数据库初始化:统一配置SQLAlchemy,动态创建数据库路径和表 - 项目模型新增开始日期和结束日期字段支持 - 添加导入批次历史记录模型支持 - 优化工具函数中日期类型提示,移除无用导入 - 更新requirements.txt依赖版本回退,确保兼容性 - 前端菜单添加导入历史导航入口,实现页面访问路由绑定
This commit is contained in:
373
static/js/common.js
Normal file
373
static/js/common.js
Normal file
@@ -0,0 +1,373 @@
|
||||
// 公共工具函数和API调用
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE_URL = '';
|
||||
|
||||
// 通用 API 调用函数
|
||||
async function apiCall(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET 请求
|
||||
async function apiGet(url) {
|
||||
return apiCall(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
// POST 请求
|
||||
async function apiPost(url, data) {
|
||||
return apiCall(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PUT 请求
|
||||
async function apiPut(url, data) {
|
||||
return apiCall(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE 请求
|
||||
async function apiDelete(url) {
|
||||
return apiCall(url, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
function showSuccess(message) {
|
||||
showNotification(message, 'success');
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
function showError(message) {
|
||||
showNotification(message, 'error');
|
||||
}
|
||||
|
||||
// 显示通知
|
||||
function showNotification(message, type = 'info') {
|
||||
// 创建通知元素
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.innerHTML = `
|
||||
<span>${message}</span>
|
||||
<button onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
// 添加到页面
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 自动隐藏
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
// 获取今天的日期字符串
|
||||
function getTodayString() {
|
||||
const today = new Date();
|
||||
return today.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 获取本周的开始和结束日期
|
||||
function getThisWeekRange() {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const startOfWeek = new Date(today);
|
||||
startOfWeek.setDate(today.getDate() - dayOfWeek + 1); // 周一
|
||||
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6); // 周日
|
||||
|
||||
return {
|
||||
start: startOfWeek.toISOString().split('T')[0],
|
||||
end: endOfWeek.toISOString().split('T')[0]
|
||||
};
|
||||
}
|
||||
|
||||
// 模态框控制
|
||||
function showModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add('show');
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 表单重置
|
||||
function resetForm(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取表单数据
|
||||
function getFormData(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return {};
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 填充表单数据
|
||||
function fillForm(formId, data) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
const field = form.querySelector(`[name="${key}"]`);
|
||||
if (field) {
|
||||
field.value = data[key] || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 工时格式化函数
|
||||
function formatHours(hours) {
|
||||
if (!hours || hours === '-' || hours === '0:00') {
|
||||
return hours || '-';
|
||||
}
|
||||
|
||||
// 如果包含冒号,直接返回
|
||||
if (hours.includes(':')) {
|
||||
return hours;
|
||||
}
|
||||
|
||||
// 如果是小数格式,转换为时:分格式
|
||||
const decimal = parseFloat(hours);
|
||||
if (!isNaN(decimal)) {
|
||||
const h = Math.floor(decimal);
|
||||
const m = Math.round((decimal - h) * 60);
|
||||
return `${h}:${m.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
// 工时转换为小数
|
||||
function hoursToDecimal(hours) {
|
||||
if (!hours || hours === '-') return 0;
|
||||
|
||||
if (hours.includes(':')) {
|
||||
const [h, m] = hours.split(':').map(Number);
|
||||
return h + (m || 0) / 60;
|
||||
}
|
||||
|
||||
return parseFloat(hours) || 0;
|
||||
}
|
||||
|
||||
// 小数转换为工时格式
|
||||
function decimalToHours(decimal) {
|
||||
if (decimal === 0) return '0:00';
|
||||
|
||||
const hours = Math.floor(decimal);
|
||||
const minutes = Math.round((decimal - hours) * 60);
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 计算工时
|
||||
function calculateHours(startTime, endTime) {
|
||||
if (!startTime || !endTime || startTime === '-' || endTime === '-') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
try {
|
||||
const [startH, startM] = startTime.split(':').map(Number);
|
||||
const [endH, endM] = endTime.split(':').map(Number);
|
||||
|
||||
let startMinutes = startH * 60 + startM;
|
||||
let endMinutes = endH * 60 + endM;
|
||||
|
||||
// 处理跨日情况
|
||||
if (endMinutes < startMinutes) {
|
||||
endMinutes += 24 * 60;
|
||||
}
|
||||
|
||||
const diffMinutes = endMinutes - startMinutes;
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
const minutes = diffMinutes % 60;
|
||||
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
} catch (error) {
|
||||
console.error('计算工时失败:', error);
|
||||
return '0:00';
|
||||
}
|
||||
}
|
||||
|
||||
// 星期中文显示
|
||||
function getDayOfWeekChinese(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
|
||||
// 判断是否为今天
|
||||
function isToday(dateString) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return dateString === today;
|
||||
}
|
||||
|
||||
// 文件下载
|
||||
function downloadFile(content, filename, type = 'text/plain') {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// CSV 生成
|
||||
function generateCSV(data, headers) {
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...data.map(row => headers.map(header => {
|
||||
const value = row[header] || '';
|
||||
// 如果值包含逗号、引号或换行符,需要用引号包围
|
||||
if (value.toString().includes(',') || value.toString().includes('"') || value.toString().includes('\n')) {
|
||||
return `"${value.toString().replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}).join(','))
|
||||
].join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
// 页面加载完成后的初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 添加点击外部关闭模态框的功能
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
e.target.classList.remove('show');
|
||||
e.target.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 添加ESC键关闭模态框的功能
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const openModal = document.querySelector('.modal.show');
|
||||
if (openModal) {
|
||||
openModal.classList.remove('show');
|
||||
openModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 添加通知样式到头部(如果不存在)
|
||||
if (!document.querySelector('#notification-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'notification-styles';
|
||||
style.textContent = `
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
background-color: #3498db;
|
||||
}
|
||||
|
||||
.notification button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
210
static/js/dashboard.js
Normal file
210
static/js/dashboard.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// 首页面板JavaScript
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDashboardStats();
|
||||
loadRecentRecords();
|
||||
});
|
||||
|
||||
// 加载仪表板统计数据
|
||||
async function loadDashboardStats() {
|
||||
try {
|
||||
// 并行加载项目和工时统计
|
||||
const [projectsResponse, recentRecordsResponse] = await Promise.all([
|
||||
apiGet('/api/projects'),
|
||||
loadThisWeekHours()
|
||||
]);
|
||||
|
||||
// 更新活跃项目数
|
||||
const activeProjects = projectsResponse.data.filter(p => p.is_active).length;
|
||||
updateStatValue('total-projects', activeProjects);
|
||||
|
||||
// 更新本周工时
|
||||
updateStatValue('this-week-hours', recentRecordsResponse.weeklyHours || '0:00');
|
||||
|
||||
// 加载本月记录数
|
||||
const thisMonthCount = await loadThisMonthRecordsCount();
|
||||
updateStatValue('this-month-records', thisMonthCount);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载仪表板统计失败:', error);
|
||||
// 显示默认值
|
||||
updateStatValue('total-projects', '0');
|
||||
updateStatValue('this-week-hours', '0:00');
|
||||
updateStatValue('this-month-records', '0');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新统计值
|
||||
function updateStatValue(elementId, value) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载本周工时
|
||||
async function loadThisWeekHours() {
|
||||
try {
|
||||
const weekRange = getThisWeekRange();
|
||||
const url = `/api/timerecords?start_date=${weekRange.start}&end_date=${weekRange.end}`;
|
||||
const response = await apiGet(url);
|
||||
|
||||
// 计算本周总工时
|
||||
let totalHours = 0;
|
||||
response.data.forEach(record => {
|
||||
if (record.hours && record.hours !== '-') {
|
||||
totalHours += hoursToDecimal(record.hours);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
weeklyHours: decimalToHours(totalHours),
|
||||
recordCount: response.data.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('加载本周工时失败:', error);
|
||||
return { weeklyHours: '0:00', recordCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 加载本月记录数
|
||||
async function loadThisMonthRecordsCount() {
|
||||
try {
|
||||
const today = new Date();
|
||||
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
const startDate = firstDayOfMonth.toISOString().split('T')[0];
|
||||
const endDate = lastDayOfMonth.toISOString().split('T')[0];
|
||||
|
||||
const url = `/api/timerecords?start_date=${startDate}&end_date=${endDate}`;
|
||||
const response = await apiGet(url);
|
||||
|
||||
return response.data.length;
|
||||
} catch (error) {
|
||||
console.error('加载本月记录数失败:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近记录
|
||||
async function loadRecentRecords() {
|
||||
try {
|
||||
// 获取最近7天的记录
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - 6); // 最近7天
|
||||
|
||||
const url = `/api/timerecords?start_date=${startDate.toISOString().split('T')[0]}&end_date=${endDate.toISOString().split('T')[0]}`;
|
||||
const response = await apiGet(url);
|
||||
|
||||
renderRecentRecords(response.data.slice(0, 5)); // 只显示最近5条
|
||||
} catch (error) {
|
||||
console.error('加载最近记录失败:', error);
|
||||
const container = document.getElementById('recent-records-list');
|
||||
if (container) {
|
||||
container.innerHTML = '<p class="text-center">加载失败</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染最近记录
|
||||
function renderRecentRecords(records) {
|
||||
const container = document.getElementById('recent-records-list');
|
||||
if (!container) return;
|
||||
|
||||
if (records.length === 0) {
|
||||
container.innerHTML = '<p class="text-center">暂无最近记录</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="recent-records-table">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>事件</th>
|
||||
<th>项目</th>
|
||||
<th>工时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${records.map(record => {
|
||||
const projectDisplay = record.project
|
||||
? (record.project.project_type === 'traditional'
|
||||
? `${record.project.customer_name} ${record.project.project_code}`
|
||||
: `${record.project.customer_name} ${record.project.contract_number}`)
|
||||
: '-';
|
||||
|
||||
const rowClass = getRowClass(record);
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>
|
||||
${formatDate(record.date)}
|
||||
${isToday(record.date) ? '<span class="today-badge">今天</span>' : ''}
|
||||
</td>
|
||||
<td>${escapeHtml(record.event_description || '-')}</td>
|
||||
<td>${escapeHtml(projectDisplay)}</td>
|
||||
<td>${record.hours || '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 获取行的CSS类名(根据是否为休息日)
|
||||
function getRowClass(record) {
|
||||
if (record.is_holiday) {
|
||||
if (record.is_working_on_holiday) {
|
||||
return 'working-holiday-row'; // 休息日工作
|
||||
} else {
|
||||
return 'holiday-row'; // 休息日休息
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// HTML转义函数
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 添加今天徽章样式
|
||||
if (!document.querySelector('#today-badge-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'today-badge-styles';
|
||||
style.textContent = `
|
||||
.today-badge {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.recent-records-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.recent-records-table .data-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.recent-records-table .data-table td {
|
||||
font-size: 13px;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
386
static/js/projects.js
Normal file
386
static/js/projects.js
Normal file
@@ -0,0 +1,386 @@
|
||||
// 项目管理页面JavaScript
|
||||
|
||||
let projects = [];
|
||||
let currentEditingProject = null;
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjects();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// 设置事件监听器
|
||||
function setupEventListeners() {
|
||||
// 项目表单提交
|
||||
const projectForm = document.getElementById('project-form');
|
||||
if (projectForm) {
|
||||
projectForm.addEventListener('submit', handleProjectSubmit);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载项目列表
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await apiGet('/api/projects');
|
||||
projects = response.data;
|
||||
renderProjectsTable();
|
||||
updateProjectStats();
|
||||
} catch (error) {
|
||||
showError('加载项目失败: ' + error.message);
|
||||
console.error('加载项目失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染项目表格
|
||||
function renderProjectsTable() {
|
||||
const tbody = document.getElementById('projects-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (projects.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center">暂无项目数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = projects.map(project => `
|
||||
<tr>
|
||||
<td>${escapeHtml(project.project_name)}</td>
|
||||
<td>
|
||||
<span class="badge ${project.project_type === 'traditional' ? 'badge-primary' : 'badge-secondary'}">
|
||||
${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${escapeHtml(project.customer_name)}</td>
|
||||
<td>
|
||||
${project.project_type === 'traditional'
|
||||
? escapeHtml(project.project_code)
|
||||
: escapeHtml(project.contract_number || 'PSI-PROJ')}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${project.is_active ? 'badge-success' : 'badge-danger'}">
|
||||
${project.is_active ? '活跃' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${formatDate(project.start_date)}</td>
|
||||
<td>${formatDateTime(project.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="editProject(${project.id})">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 更新项目统计
|
||||
function updateProjectStats() {
|
||||
const totalCount = projects.length;
|
||||
const traditionalCount = projects.filter(p => p.project_type === 'traditional').length;
|
||||
const psiCount = projects.filter(p => p.project_type === 'psi').length;
|
||||
|
||||
const totalElement = document.getElementById('total-projects-count');
|
||||
const traditionalElement = document.getElementById('traditional-projects-count');
|
||||
const psiElement = document.getElementById('psi-projects-count');
|
||||
|
||||
if (totalElement) totalElement.textContent = totalCount;
|
||||
if (traditionalElement) traditionalElement.textContent = traditionalCount;
|
||||
if (psiElement) psiElement.textContent = psiCount;
|
||||
}
|
||||
|
||||
// 筛选项目
|
||||
function filterProjects() {
|
||||
const typeFilter = document.getElementById('project-type-filter').value;
|
||||
|
||||
let filteredProjects = projects;
|
||||
if (typeFilter) {
|
||||
filteredProjects = projects.filter(p => p.project_type === typeFilter);
|
||||
}
|
||||
|
||||
renderFilteredProjects(filteredProjects);
|
||||
}
|
||||
|
||||
// 渲染筛选后的项目
|
||||
function renderFilteredProjects(filteredProjects) {
|
||||
const tbody = document.getElementById('projects-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (filteredProjects.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center">没有符合条件的项目</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filteredProjects.map(project => `
|
||||
<tr>
|
||||
<td>${escapeHtml(project.project_name)}</td>
|
||||
<td>
|
||||
<span class="badge ${project.project_type === 'traditional' ? 'badge-primary' : 'badge-secondary'}">
|
||||
${project.project_type === 'traditional' ? '传统项目' : 'PSI项目'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${escapeHtml(project.customer_name)}</td>
|
||||
<td>
|
||||
${project.project_type === 'traditional'
|
||||
? escapeHtml(project.project_code)
|
||||
: escapeHtml(project.contract_number || 'PSI-PROJ')}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${project.is_active ? 'badge-success' : 'badge-danger'}">
|
||||
${project.is_active ? '活跃' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>${formatDate(project.start_date)}</td>
|
||||
<td>${formatDateTime(project.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="editProject(${project.id})">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 显示创建项目模态框
|
||||
function showCreateProjectModal() {
|
||||
currentEditingProject = null;
|
||||
resetForm('project-form');
|
||||
document.getElementById('modal-title').textContent = '新建项目';
|
||||
|
||||
// 隐藏项目类型特定字段
|
||||
document.getElementById('traditional-fields').style.display = 'none';
|
||||
document.getElementById('psi-fields').style.display = 'none';
|
||||
|
||||
showModal('create-project-modal');
|
||||
}
|
||||
|
||||
// 项目类型切换
|
||||
function toggleProjectFields() {
|
||||
const projectType = document.getElementById('project_type').value;
|
||||
const traditionalFields = document.getElementById('traditional-fields');
|
||||
const psiFields = document.getElementById('psi-fields');
|
||||
const projectCodeField = document.getElementById('project_code');
|
||||
const contractNumberField = document.getElementById('contract_number');
|
||||
|
||||
if (projectType === 'traditional') {
|
||||
traditionalFields.style.display = 'block';
|
||||
psiFields.style.display = 'none';
|
||||
projectCodeField.required = true;
|
||||
contractNumberField.required = false;
|
||||
} else if (projectType === 'psi') {
|
||||
traditionalFields.style.display = 'none';
|
||||
psiFields.style.display = 'block';
|
||||
projectCodeField.required = false;
|
||||
contractNumberField.required = true;
|
||||
} else {
|
||||
traditionalFields.style.display = 'none';
|
||||
psiFields.style.display = 'none';
|
||||
projectCodeField.required = false;
|
||||
contractNumberField.required = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理项目表单提交
|
||||
async function handleProjectSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = getFormData('project-form');
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (currentEditingProject) {
|
||||
// 更新项目
|
||||
response = await apiPut(`/api/projects/${currentEditingProject.id}`, formData);
|
||||
} else {
|
||||
// 创建新项目
|
||||
response = await apiPost('/api/projects', formData);
|
||||
}
|
||||
|
||||
showSuccess(currentEditingProject ? '项目更新成功' : '项目创建成功');
|
||||
closeModal('create-project-modal');
|
||||
loadProjects(); // 重新加载项目列表
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑项目
|
||||
function editProject(projectId) {
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
if (!project) {
|
||||
showError('项目不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditingProject = project;
|
||||
document.getElementById('modal-title').textContent = '编辑项目';
|
||||
|
||||
// 填充表单数据
|
||||
fillForm('project-form', project);
|
||||
|
||||
// 设置项目类型并显示对应字段
|
||||
document.getElementById('project_type').value = project.project_type;
|
||||
toggleProjectFields();
|
||||
|
||||
showModal('create-project-modal');
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
async function deleteProject(projectId) {
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
if (!project) {
|
||||
showError('项目不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除项目"${project.project_name}"吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiDelete(`/api/projects/${projectId}`);
|
||||
showSuccess('项目删除成功');
|
||||
loadProjects(); // 重新加载项目列表
|
||||
} catch (error) {
|
||||
showError('删除项目失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示导入模态框
|
||||
function showImportModal() {
|
||||
showModal('import-modal');
|
||||
}
|
||||
|
||||
// 下载模板文件
|
||||
function downloadTemplate(type) {
|
||||
let csvContent = '';
|
||||
|
||||
if (type === 'traditional') {
|
||||
csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述
|
||||
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
|
||||
Project Alpha,traditional,客户A,01A-DEV,,Alpha开发项目`;
|
||||
} else if (type === 'psi') {
|
||||
csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述
|
||||
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
|
||||
Samsung项目,psi,Samsung,PSI-PROJ,SC20241201,Samsung客户项目`;
|
||||
} else if (type === 'mixed') {
|
||||
csvContent = `项目名称,项目类型,客户名,项目代码,合同号,描述
|
||||
CXMT 2025 MA,traditional,长鑫存储,02C-FBV,,长鑫2025年MA项目
|
||||
NexChip PSI项目,psi,NexChip,PSI-PROJ,ID00462761,NexChip客户PSI项目
|
||||
Project Beta,traditional,客户B,01B-TEST,,Beta测试项目`;
|
||||
}
|
||||
|
||||
downloadFile(csvContent, `项目模板_${type}.csv`, 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
// 导入项目
|
||||
async function importProjects() {
|
||||
const fileInput = document.getElementById('import-file');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
showError('请选择CSV文件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
showError('请选择CSV文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '导入失败');
|
||||
}
|
||||
|
||||
// 显示导入结果
|
||||
showImportResult(result);
|
||||
closeModal('import-modal');
|
||||
loadProjects(); // 重新加载项目列表
|
||||
} catch (error) {
|
||||
showError('导入项目失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示导入结果
|
||||
function showImportResult(result) {
|
||||
const content = document.getElementById('import-result-content');
|
||||
|
||||
let html = `<div class="import-summary">
|
||||
<h4>导入完成</h4>
|
||||
<p>成功导入 <strong>${result.created_count}</strong> 个项目</p>
|
||||
</div>`;
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
html += `<div class="import-errors">
|
||||
<h5>导入错误 (${result.errors.length} 项):</h5>
|
||||
<ul>`;
|
||||
|
||||
result.errors.forEach(error => {
|
||||
html += `<li>${escapeHtml(error)}</li>`;
|
||||
});
|
||||
|
||||
html += `</ul></div>`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showModal('import-result-modal');
|
||||
}
|
||||
|
||||
// HTML转义函数
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 添加徽章样式(如果CSS中没有定义)
|
||||
if (!document.querySelector('#badge-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'badge-styles';
|
||||
style.textContent = `
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
485
static/js/statistics.js
Normal file
485
static/js/statistics.js
Normal file
@@ -0,0 +1,485 @@
|
||||
// 统计分析页面JavaScript
|
||||
|
||||
let cutoffPeriods = [];
|
||||
let currentStats = null;
|
||||
let projectHoursChart = null; // 用于存储图表实例
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCutoffPeriods();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// 设置事件监听器
|
||||
function setupEventListeners() {
|
||||
// 周期表单提交
|
||||
const periodForm = document.getElementById('period-form');
|
||||
if (periodForm) {
|
||||
periodForm.addEventListener('submit', handlePeriodSubmit);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载Cut-Off周期
|
||||
async function loadCutoffPeriods() {
|
||||
try {
|
||||
const response = await apiGet('/api/statistics/periods');
|
||||
cutoffPeriods = response.data;
|
||||
populatePeriodSelect();
|
||||
} catch (error) {
|
||||
showError('加载周期列表失败: ' + error.message);
|
||||
console.error('加载周期列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 填充周期选择框
|
||||
function populatePeriodSelect() {
|
||||
const select = document.getElementById('period-select');
|
||||
if (!select) return;
|
||||
|
||||
// 清空现有选项(保留第一个默认选项)
|
||||
const firstOption = select.querySelector('option');
|
||||
select.innerHTML = '';
|
||||
if (firstOption) {
|
||||
select.appendChild(firstOption);
|
||||
}
|
||||
|
||||
// 添加周期选项
|
||||
cutoffPeriods.forEach(period => {
|
||||
const option = document.createElement('option');
|
||||
option.value = period.id;
|
||||
option.textContent = `${period.period_name} (${formatDate(period.start_date)} - ${formatDate(period.end_date)})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 加载周统计数据
|
||||
async function loadWeeklyStats() {
|
||||
const periodId = document.getElementById('period-select').value;
|
||||
|
||||
if (!periodId) {
|
||||
hideStatsDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiGet(`/api/statistics/weekly?period_id=${periodId}`);
|
||||
currentStats = response.data;
|
||||
displayStats();
|
||||
} catch (error) {
|
||||
showError('加载统计数据失败: ' + error.message);
|
||||
console.error('加载统计数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载自定义日期范围统计
|
||||
async function loadCustomStats() {
|
||||
const startDate = document.getElementById('custom-start-date').value;
|
||||
const endDate = document.getElementById('custom-end-date').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showError('请选择开始和结束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
showError('开始日期不能晚于结束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiGet(`/api/statistics/weekly?start_date=${startDate}&end_date=${endDate}`);
|
||||
currentStats = response.data;
|
||||
displayStats();
|
||||
|
||||
// 清空周期选择
|
||||
document.getElementById('period-select').value = '';
|
||||
} catch (error) {
|
||||
showError('加载统计数据失败: ' + error.message);
|
||||
console.error('加载统计数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示统计数据
|
||||
function displayStats() {
|
||||
if (!currentStats) return;
|
||||
|
||||
showStatsDisplay();
|
||||
updateStatsOverview();
|
||||
renderDailyDetails();
|
||||
renderProjectDistribution();
|
||||
}
|
||||
|
||||
// 显示统计界面
|
||||
function showStatsDisplay() {
|
||||
document.getElementById('stats-overview').style.display = 'block';
|
||||
document.getElementById('daily-details').style.display = 'block';
|
||||
document.getElementById('project-distribution').style.display = 'block';
|
||||
}
|
||||
|
||||
// 隐藏统计界面
|
||||
function hideStatsDisplay() {
|
||||
document.getElementById('stats-overview').style.display = 'none';
|
||||
document.getElementById('daily-details').style.display = 'none';
|
||||
document.getElementById('project-distribution').style.display = 'none';
|
||||
}
|
||||
|
||||
// 更新统计概览
|
||||
function updateStatsOverview() {
|
||||
if (!currentStats) return;
|
||||
|
||||
// 更新周期信息
|
||||
document.getElementById('current-period-name').textContent = currentStats.period.period_name;
|
||||
document.getElementById('period-date-range').textContent =
|
||||
`${formatDate(currentStats.period.start_date)} - ${formatDate(currentStats.period.end_date)}`;
|
||||
|
||||
// 更新统计数据
|
||||
document.getElementById('workday-total').textContent = currentStats.workday_total;
|
||||
document.getElementById('holiday-total').textContent = currentStats.holiday_total;
|
||||
document.getElementById('weekly-total').textContent = currentStats.weekly_total;
|
||||
document.getElementById('completion-rate').textContent = `${currentStats.completion_rate}%`;
|
||||
|
||||
// 更新工作天数统计
|
||||
document.getElementById('working-days').textContent = currentStats.working_days;
|
||||
document.getElementById('holiday-work-days').textContent = currentStats.holiday_work_days;
|
||||
document.getElementById('rest-days').textContent = currentStats.rest_days;
|
||||
|
||||
// 更新完成度颜色
|
||||
const completionElement = document.getElementById('completion-rate');
|
||||
completionElement.className = 'stat-value';
|
||||
if (currentStats.completion_rate >= 100) {
|
||||
completionElement.style.color = '#27ae60';
|
||||
} else if (currentStats.completion_rate >= 80) {
|
||||
completionElement.style.color = '#f39c12';
|
||||
} else {
|
||||
completionElement.style.color = '#e74c3c';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染每日详情
|
||||
function renderDailyDetails() {
|
||||
if (!currentStats || !currentStats.daily_records) return;
|
||||
|
||||
const tbody = document.getElementById('daily-stats-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = currentStats.daily_records.map(record => {
|
||||
const rowClass = getRowClass(record);
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${formatDate(record.date)}</td>
|
||||
<td>${record.day_of_week}</td>
|
||||
<td>${escapeHtml(record.event)}</td>
|
||||
<td>${escapeHtml(record.project)}</td>
|
||||
<td>${record.start_time}</td>
|
||||
<td>${record.end_time}</td>
|
||||
<td>${escapeHtml(record.activity_num)}</td>
|
||||
<td>${record.hours}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 渲染项目工时分布图表和表格
|
||||
function renderProjectDistribution() {
|
||||
if (!currentStats || !currentStats.project_hours) return;
|
||||
|
||||
const projectData = currentStats.project_hours;
|
||||
const tableBody = document.getElementById('project-hours-tbody');
|
||||
const ctx = document.getElementById('project-hours-chart').getContext('2d');
|
||||
|
||||
if (!ctx || !tableBody) return;
|
||||
|
||||
// 清理旧内容
|
||||
tableBody.innerHTML = '';
|
||||
if (projectHoursChart) {
|
||||
projectHoursChart.destroy();
|
||||
}
|
||||
|
||||
if (projectData.length === 0) {
|
||||
// 在表格中显示无数据信息
|
||||
tableBody.innerHTML = '<tr><td colspan="3" style="text-align: center;">暂无项目工时数据</td></tr>';
|
||||
|
||||
// 清理画布
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.font = "16px Arial";
|
||||
ctx.fillStyle = "#888";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("暂无项目工时数据", ctx.canvas.width / 2, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// 填充表格
|
||||
projectData.forEach(item => {
|
||||
const row = `
|
||||
<tr>
|
||||
<td>${escapeHtml(item.project)}</td>
|
||||
<td>${item.hours}</td>
|
||||
<td>${item.percentage}%</td>
|
||||
</tr>
|
||||
`;
|
||||
tableBody.innerHTML += row;
|
||||
});
|
||||
|
||||
// 准备图表数据
|
||||
const labels = projectData.map(item => item.project);
|
||||
const data = projectData.map(item => parseFloat(item.hours.replace(':', '.')));
|
||||
|
||||
const backgroundColors = [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40',
|
||||
'#E7E9ED', '#8DDF3C', '#FFD700', '#B22222', '#4682B4', '#D2B48C'
|
||||
];
|
||||
|
||||
// 渲染图表
|
||||
projectHoursChart = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '工时',
|
||||
data: data,
|
||||
backgroundColor: backgroundColors.slice(0, data.length),
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
},
|
||||
title: {
|
||||
display: false, // 标题可以省略,因为旁边有表格
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed !== null) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = ((context.parsed / total) * 100).toFixed(2);
|
||||
label += `${context.raw} 小时 (${percentage}%)`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取行的CSS类名(根据是否为休息日)
|
||||
function getRowClass(record) {
|
||||
if (record.is_holiday) {
|
||||
if (record.is_working_on_holiday) {
|
||||
return 'working-holiday-row'; // 休息日工作
|
||||
} else {
|
||||
return 'holiday-row'; // 休息日休息
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 显示创建周期模态框
|
||||
function showCreatePeriodModal() {
|
||||
resetForm('period-form');
|
||||
showModal('create-period-modal');
|
||||
}
|
||||
|
||||
// 计算周期信息
|
||||
function calculatePeriodInfo() {
|
||||
const startDate = document.getElementById('start_date').value;
|
||||
const endDate = document.getElementById('end_date').value;
|
||||
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (start >= end) {
|
||||
showError('开始日期必须早于结束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算天数和周数
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
const weeks = Math.round(daysDiff / 7);
|
||||
|
||||
// 更新周数字段
|
||||
document.getElementById('weeks').value = weeks;
|
||||
|
||||
// 更新目标工时(如果为空)
|
||||
const targetHoursField = document.getElementById('target_hours');
|
||||
if (!targetHoursField.value) {
|
||||
targetHoursField.value = weeks * 40; // 默认每周40小时
|
||||
}
|
||||
}
|
||||
|
||||
// 应用周期模板
|
||||
function applyTemplate(templateType) {
|
||||
const today = new Date();
|
||||
let startDate, endDate, weeks, targetHours, periodName;
|
||||
|
||||
switch (templateType) {
|
||||
case 'weekly':
|
||||
// 本周周期
|
||||
const dayOfWeek = today.getDay();
|
||||
const startOfWeek = new Date(today);
|
||||
startOfWeek.setDate(today.getDate() - dayOfWeek + 1); // 周一
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6); // 周日
|
||||
|
||||
startDate = startOfWeek.toISOString().split('T')[0];
|
||||
endDate = endOfWeek.toISOString().split('T')[0];
|
||||
weeks = 1;
|
||||
targetHours = 40;
|
||||
periodName = `${today.getFullYear()}年第${getWeekNumber(today)}周`;
|
||||
break;
|
||||
|
||||
case 'biweekly':
|
||||
// 双周周期
|
||||
startDate = getTodayString();
|
||||
const biweekEnd = new Date(today);
|
||||
biweekEnd.setDate(today.getDate() + 13);
|
||||
endDate = biweekEnd.toISOString().split('T')[0];
|
||||
weeks = 2;
|
||||
targetHours = 80;
|
||||
periodName = `双周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
|
||||
break;
|
||||
|
||||
case 'four-weeks':
|
||||
// 4周周期
|
||||
startDate = getTodayString();
|
||||
const fourWeeksEnd = new Date(today);
|
||||
fourWeeksEnd.setDate(today.getDate() + 27);
|
||||
endDate = fourWeeksEnd.toISOString().split('T')[0];
|
||||
weeks = 4;
|
||||
targetHours = 160;
|
||||
periodName = `4周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
|
||||
break;
|
||||
|
||||
case 'five-weeks':
|
||||
// 5周周期
|
||||
startDate = getTodayString();
|
||||
const fiveWeeksEnd = new Date(today);
|
||||
fiveWeeksEnd.setDate(today.getDate() + 34);
|
||||
endDate = fiveWeeksEnd.toISOString().split('T')[0];
|
||||
weeks = 5;
|
||||
targetHours = 200;
|
||||
periodName = `5周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
// 默认行为可以指向一个常用模板,例如4周
|
||||
startDate = getTodayString();
|
||||
const defaultEnd = new Date(today);
|
||||
defaultEnd.setDate(today.getDate() + 27);
|
||||
endDate = defaultEnd.toISOString().split('T')[0];
|
||||
weeks = 4;
|
||||
targetHours = 160;
|
||||
periodName = `4周周期 ${formatDate(startDate)}-${formatDate(endDate)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// 填充表单
|
||||
document.getElementById('period_name').value = periodName;
|
||||
document.getElementById('start_date').value = startDate;
|
||||
document.getElementById('end_date').value = endDate;
|
||||
document.getElementById('weeks').value = weeks;
|
||||
document.getElementById('target_hours').value = targetHours;
|
||||
}
|
||||
|
||||
// 获取周数
|
||||
function getWeekNumber(date) {
|
||||
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
|
||||
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
||||
}
|
||||
|
||||
// 处理周期表单提交
|
||||
async function handlePeriodSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = getFormData('period-form');
|
||||
|
||||
// 验证必填字段
|
||||
if (!formData.period_name || !formData.start_date || !formData.end_date) {
|
||||
showError('请填写完整的周期信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiPost('/api/statistics/periods', formData);
|
||||
showSuccess('Cut-Off周期创建成功');
|
||||
closeModal('create-period-modal');
|
||||
loadCutoffPeriods(); // 重新加载周期列表
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 管理周期
|
||||
function managePeriods() {
|
||||
loadPeriodsTable();
|
||||
showModal('manage-periods-modal');
|
||||
}
|
||||
|
||||
// 加载周期管理表格
|
||||
function loadPeriodsTable() {
|
||||
const tbody = document.getElementById('periods-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (cutoffPeriods.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center">暂无周期数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = cutoffPeriods.map(period => `
|
||||
<tr>
|
||||
<td>${escapeHtml(period.period_name)}</td>
|
||||
<td>${formatDate(period.start_date)}</td>
|
||||
<td>${formatDate(period.end_date)}</td>
|
||||
<td>${period.weeks}</td>
|
||||
<td>${period.target_hours}小时</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deletePeriod(${period.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 删除周期
|
||||
async function deletePeriod(periodId) {
|
||||
const period = cutoffPeriods.find(p => p.id === periodId);
|
||||
if (!period) {
|
||||
showError('周期不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除周期"${period.period_name}"吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiDelete(`/api/statistics/periods/${periodId}`);
|
||||
showSuccess('周期删除成功');
|
||||
loadCutoffPeriods(); // 重新加载周期列表
|
||||
loadPeriodsTable(); // 更新管理表格
|
||||
} catch (error) {
|
||||
showError('删除周期失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// HTML转义函数
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
347
static/js/timerecords.js
Normal file
347
static/js/timerecords.js
Normal file
@@ -0,0 +1,347 @@
|
||||
// 工时记录页面JavaScript
|
||||
|
||||
let timeRecords = [];
|
||||
let projects = [];
|
||||
let currentEditingRecord = null;
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializePage();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// 初始化页面
|
||||
async function initializePage() {
|
||||
await Promise.all([
|
||||
loadProjects(),
|
||||
loadTimeRecords()
|
||||
]);
|
||||
|
||||
// 设置默认筛选日期为本周
|
||||
const weekRange = getThisWeekRange();
|
||||
document.getElementById('filter-start-date').value = weekRange.start;
|
||||
document.getElementById('filter-end-date').value = weekRange.end;
|
||||
|
||||
// 设置默认记录日期为今天
|
||||
document.getElementById('record_date').value = getTodayString();
|
||||
}
|
||||
|
||||
// 设置事件监听器
|
||||
function setupEventListeners() {
|
||||
// 工时记录表单提交
|
||||
const recordForm = document.getElementById('timerecord-form');
|
||||
if (recordForm) {
|
||||
recordForm.addEventListener('submit', handleRecordSubmit);
|
||||
}
|
||||
|
||||
// 时间输入变化时自动计算工时
|
||||
const startTimeInput = document.getElementById('start_time');
|
||||
const endTimeInput = document.getElementById('end_time');
|
||||
|
||||
if (startTimeInput) {
|
||||
startTimeInput.addEventListener('change', calculateHours);
|
||||
}
|
||||
if (endTimeInput) {
|
||||
endTimeInput.addEventListener('change', calculateHours);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载项目列表
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await apiGet('/api/projects');
|
||||
projects = response.data;
|
||||
populateProjectSelect();
|
||||
} catch (error) {
|
||||
showError('加载项目失败: ' + error.message);
|
||||
console.error('加载项目失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 填充项目选择框
|
||||
function populateProjectSelect() {
|
||||
const selects = ['project_id', 'filter-project'];
|
||||
|
||||
selects.forEach(selectId => {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
|
||||
// 清空现有选项(保留第一个默认选项)
|
||||
const firstOption = select.querySelector('option');
|
||||
select.innerHTML = '';
|
||||
if (firstOption) {
|
||||
select.appendChild(firstOption);
|
||||
}
|
||||
|
||||
// 添加项目选项
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.project_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加载工时记录
|
||||
async function loadTimeRecords() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const startDate = document.getElementById('filter-start-date').value;
|
||||
const endDate = document.getElementById('filter-end-date').value;
|
||||
const projectId = document.getElementById('filter-project').value;
|
||||
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (projectId) params.append('project_id', projectId);
|
||||
|
||||
const url = `/api/timerecords${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await apiGet(url);
|
||||
timeRecords = response.data;
|
||||
renderTimeRecordsTable();
|
||||
} catch (error) {
|
||||
showError('加载工时记录失败: ' + error.message);
|
||||
console.error('加载工时记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染工时记录表格
|
||||
function renderTimeRecordsTable() {
|
||||
const tbody = document.getElementById('timerecords-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (timeRecords.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center">暂无工时记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = timeRecords.map(record => {
|
||||
const projectDisplay = record.project ? record.project.project_name : '-';
|
||||
|
||||
const rowClass = getRowClass(record);
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td>${formatDate(record.date)}</td>
|
||||
<td>${record.day_of_week || getDayOfWeekChinese(record.date)}</td>
|
||||
<td>${escapeHtml(record.event_description || '-')}</td>
|
||||
<td>${escapeHtml(projectDisplay)}</td>
|
||||
<td>${record.start_time || '-'}</td>
|
||||
<td>${record.end_time || '-'}</td>
|
||||
<td>${escapeHtml(record.activity_num || '-')}</td>
|
||||
<td>${record.hours || '-'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="editRecord(${record.id})">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteRecord(${record.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 获取行的CSS类名(根据是否为休息日)
|
||||
function getRowClass(record) {
|
||||
if (record.is_holiday) {
|
||||
if (record.is_working_on_holiday) {
|
||||
return 'working-holiday-row'; // 休息日工作
|
||||
} else {
|
||||
return 'holiday-row'; // 休息日休息
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
function resetFilters() {
|
||||
document.getElementById('filter-start-date').value = '';
|
||||
document.getElementById('filter-end-date').value = '';
|
||||
document.getElementById('filter-project').value = '';
|
||||
loadTimeRecords();
|
||||
}
|
||||
|
||||
// 显示创建记录模态框
|
||||
function showCreateRecordModal() {
|
||||
currentEditingRecord = null;
|
||||
resetForm('timerecord-form');
|
||||
document.getElementById('timerecord-modal-title').textContent = '新建工时记录';
|
||||
|
||||
// 设置默认日期为今天
|
||||
document.getElementById('record_date').value = getTodayString();
|
||||
|
||||
// 设置默认时间
|
||||
document.getElementById('start_time').value = '09:00';
|
||||
document.getElementById('end_time').value = '17:00';
|
||||
|
||||
// 自动计算工时
|
||||
updateHoursInput();
|
||||
|
||||
// 隐藏休息日信息和警告
|
||||
document.getElementById('holiday-info').style.display = 'none';
|
||||
document.getElementById('holiday-warning').style.display = 'none';
|
||||
|
||||
showModal('timerecord-modal');
|
||||
|
||||
// 检查今天是否为休息日
|
||||
checkHoliday();
|
||||
}
|
||||
|
||||
// 检查休息日
|
||||
async function checkHoliday() {
|
||||
const dateInput = document.getElementById('record_date');
|
||||
const date = dateInput.value;
|
||||
|
||||
if (!date) return;
|
||||
|
||||
try {
|
||||
const response = await apiGet(`/api/timerecords/check_holiday/${date}`);
|
||||
const holidayInfo = response.data;
|
||||
|
||||
updateHolidayInfo(holidayInfo);
|
||||
} catch (error) {
|
||||
console.error('检查休息日失败:', error);
|
||||
// 如果API调用失败,使用本地判断
|
||||
const dayOfWeek = new Date(date).getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
updateHolidayInfo({
|
||||
is_holiday: isWeekend,
|
||||
holiday_type: isWeekend ? 'weekend' : null,
|
||||
holiday_name: null,
|
||||
day_of_week: getDayOfWeekChinese(date)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新休息日信息显示
|
||||
function updateHolidayInfo(holidayInfo) {
|
||||
const holidayInfoDiv = document.getElementById('holiday-info');
|
||||
const holidayBadge = document.getElementById('holiday-badge');
|
||||
const holidayText = document.getElementById('holiday-text');
|
||||
const holidayWarning = document.getElementById('holiday-warning');
|
||||
|
||||
if (holidayInfo.is_holiday) {
|
||||
// 显示休息日信息
|
||||
holidayInfoDiv.style.display = 'flex';
|
||||
holidayWarning.style.display = 'block';
|
||||
|
||||
// 设置徽章样式和文本
|
||||
if (holidayInfo.holiday_type === 'weekend') {
|
||||
holidayBadge.className = 'badge weekend';
|
||||
holidayBadge.textContent = '周末';
|
||||
} else if (holidayInfo.holiday_type === 'national_holiday') {
|
||||
holidayBadge.className = 'badge national-holiday';
|
||||
holidayBadge.textContent = '节假日';
|
||||
} else {
|
||||
holidayBadge.className = 'badge weekend';
|
||||
holidayBadge.textContent = '休息日';
|
||||
}
|
||||
|
||||
holidayText.textContent = holidayInfo.holiday_name || `${holidayInfo.day_of_week} 休息日`;
|
||||
} else {
|
||||
// 隐藏休息日信息
|
||||
holidayInfoDiv.style.display = 'none';
|
||||
holidayWarning.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新工时输入框
|
||||
function updateHoursInput() {
|
||||
const startTime = document.getElementById('start_time').value;
|
||||
const endTime = document.getElementById('end_time').value;
|
||||
const hoursField = document.getElementById('hours');
|
||||
|
||||
if (startTime && endTime) {
|
||||
const calculated = calculateHours(startTime, endTime); // 调用 common.js 中的全局函数
|
||||
hoursField.value = calculated;
|
||||
} else {
|
||||
hoursField.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 处理记录表单提交
|
||||
async function handleRecordSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = getFormData('timerecord-form');
|
||||
|
||||
// 验证必填字段
|
||||
if (!formData.date) {
|
||||
showError('请选择日期');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (currentEditingRecord) {
|
||||
// 更新记录
|
||||
response = await apiPut(`/api/timerecords/${currentEditingRecord.id}`, formData);
|
||||
} else {
|
||||
// 创建新记录
|
||||
response = await apiPost('/api/timerecords', formData);
|
||||
}
|
||||
|
||||
showSuccess(currentEditingRecord ? '工时记录更新成功' : '工时记录创建成功');
|
||||
closeModal('timerecord-modal');
|
||||
location.reload(); // 刷新页面以显示最新数据
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑记录
|
||||
function editRecord(recordId) {
|
||||
const record = timeRecords.find(r => r.id === recordId);
|
||||
if (!record) {
|
||||
showError('记录不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditingRecord = record;
|
||||
document.getElementById('timerecord-modal-title').textContent = '编辑工时记录';
|
||||
|
||||
// 填充表单数据
|
||||
fillForm('timerecord-form', {
|
||||
date: record.date,
|
||||
event_description: record.event_description || '',
|
||||
project_id: record.project_id || '',
|
||||
start_time: record.start_time || '',
|
||||
end_time: record.end_time || '',
|
||||
activity_num: record.activity_num || '',
|
||||
hours: record.hours || ''
|
||||
});
|
||||
|
||||
showModal('timerecord-modal');
|
||||
|
||||
// 检查是否为休息日
|
||||
checkHoliday();
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
async function deleteRecord(recordId) {
|
||||
const record = timeRecords.find(r => r.id === recordId);
|
||||
if (!record) {
|
||||
showError('记录不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除这条工时记录吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiDelete(`/api/timerecords/${recordId}`);
|
||||
showSuccess('工时记录删除成功');
|
||||
location.reload(); // 刷新页面以显示最新数据
|
||||
} catch (error) {
|
||||
showError('删除工时记录失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// HTML转义函数
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user