init
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
200
README.md
Normal file
200
README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 工作任务管理系统
|
||||
|
||||
一个简单而强大的工作任务管理工具,帮助你记录每日工作任务和对应的时间投入。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **任务管理**: 添加、编辑、删除工作任务
|
||||
- ⏱️ **时间追踪**: 点击开始/停止计时,自动记录任务时长
|
||||
- 📊 **时间统计**: 查看每日、每周的工作时间统计
|
||||
- 🤖 **AI润色**: 使用AI优化任务描述,让工作记录更专业
|
||||
- 📱 **响应式设计**: 支持桌面和移动设备
|
||||
- 💾 **本地存储**: 数据存储在本地SQLite数据库中
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: HTML5 + CSS3 + JavaScript (Vanilla JS)
|
||||
- **后端**: Python Flask
|
||||
- **数据库**: SQLite
|
||||
- **AI服务**: OpenAI API (可选)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境要求
|
||||
|
||||
- Python 3.7 或更高版本
|
||||
- 现代浏览器 (Chrome, Firefox, Safari, Edge)
|
||||
|
||||
### 2. 安装和运行
|
||||
|
||||
#### 方法一:使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 克隆或下载项目到本地
|
||||
cd worklist
|
||||
|
||||
# 运行启动脚本
|
||||
python start.py
|
||||
```
|
||||
|
||||
启动脚本会自动:
|
||||
- 检查Python版本
|
||||
- 安装所需依赖
|
||||
- 创建配置文件
|
||||
- 启动服务器
|
||||
- 自动打开浏览器
|
||||
|
||||
#### 方法二:手动启动
|
||||
|
||||
```bash
|
||||
# 1. 安装Python依赖
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 2. 启动服务器
|
||||
cd backend
|
||||
python app.py
|
||||
|
||||
# 3. 在浏览器中访问
|
||||
# http://localhost:5000
|
||||
```
|
||||
|
||||
### 3. 配置AI润色功能(可选)
|
||||
|
||||
如需使用AI润色功能,请:
|
||||
|
||||
1. 获取OpenAI API密钥
|
||||
2. 编辑 `backend/.env` 文件
|
||||
3. 设置 `OPENAI_API_KEY=your_api_key_here`
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 基本操作
|
||||
|
||||
1. **添加任务**
|
||||
- 点击"添加任务"按钮
|
||||
- 填写任务标题和描述
|
||||
- 选择任务状态
|
||||
- 点击"保存"
|
||||
|
||||
2. **时间追踪**
|
||||
- 在任务列表中点击"开始"按钮开始计时
|
||||
- 点击"停止"按钮结束计时
|
||||
- 系统自动记录和累计任务时长
|
||||
|
||||
3. **查看统计**
|
||||
- 点击"查看报表"按钮
|
||||
- 选择日期查看当日工作统计
|
||||
- 查看各任务的时间投入
|
||||
|
||||
4. **AI润色**
|
||||
- 在编辑任务时,点击"AI润色"按钮
|
||||
- 系统会优化任务描述
|
||||
- 可选择使用润色版本或保持原版
|
||||
|
||||
### 界面说明
|
||||
|
||||
- **任务列表**: 显示所有任务,包括状态、时长等信息
|
||||
- **统计面板**: 显示今日总工作时长、完成任务数等
|
||||
- **计时器**: 实时显示当前任务计时或总时长
|
||||
- **报表**: 详细的每日工作时间统计
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
worklist/
|
||||
├── backend/ # 后端代码
|
||||
│ ├── app.py # Flask主应用
|
||||
│ ├── models.py # 数据库模型
|
||||
│ ├── routes.py # API路由
|
||||
│ ├── ai_service.py # AI服务
|
||||
│ ├── requirements.txt # Python依赖
|
||||
│ └── .env # 环境变量配置
|
||||
├── frontend/ # 前端代码
|
||||
│ ├── index.html # 主页面
|
||||
│ ├── css/
|
||||
│ │ └── style.css # 样式文件
|
||||
│ └── js/
|
||||
│ ├── app.js # 主应用逻辑
|
||||
│ ├── api.js # API调用
|
||||
│ └── timer.js # 计时器功能
|
||||
├── start.py # 启动脚本
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 任务管理
|
||||
- `GET /api/tasks` - 获取任务列表
|
||||
- `POST /api/tasks` - 创建任务
|
||||
- `PUT /api/tasks/:id` - 更新任务
|
||||
- `DELETE /api/tasks/:id` - 删除任务
|
||||
- `POST /api/tasks/:id/polish` - AI润色任务描述
|
||||
|
||||
### 时间追踪
|
||||
- `POST /api/timer/start` - 开始计时
|
||||
- `POST /api/timer/stop` - 停止计时
|
||||
- `GET /api/timer/status/:id` - 获取计时状态
|
||||
|
||||
### 统计报表
|
||||
- `GET /api/reports/daily` - 获取日报表
|
||||
- `GET /api/reports/summary` - 获取汇总报表
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 任务 (Task)
|
||||
- `id`: 任务ID
|
||||
- `title`: 任务标题
|
||||
- `description`: 任务描述
|
||||
- `polished_description`: AI润色后的描述
|
||||
- `status`: 任务状态 (pending/in_progress/completed)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
|
||||
### 时间记录 (TimeRecord)
|
||||
- `id`: 记录ID
|
||||
- `task_id`: 关联的任务ID
|
||||
- `start_time`: 开始时间
|
||||
- `end_time`: 结束时间
|
||||
- `duration`: 时长(秒)
|
||||
- `created_at`: 创建时间
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何备份数据?
|
||||
A: 数据库文件位于 `backend/worklist.db`,直接复制此文件即可备份。
|
||||
|
||||
### Q: 可以多人使用吗?
|
||||
A: 当前版本为单用户设计,如需多用户支持需要进一步开发。
|
||||
|
||||
### Q: AI润色功能需要付费吗?
|
||||
A: 需要OpenAI API密钥,按使用量计费,具体费用请查看OpenAI官网。
|
||||
|
||||
### Q: 支持数据导出吗?
|
||||
A: 当前版本暂不支持,但可以通过API获取数据,后续版本会添加导出功能。
|
||||
|
||||
## 开发说明
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 克隆项目
|
||||
2. 安装依赖: `pip install -r backend/requirements.txt`
|
||||
3. 启动开发服务器: `cd backend && python app.py`
|
||||
4. 访问: `http://localhost:5000`
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. 后端: 在 `routes.py` 中添加新的API端点
|
||||
2. 前端: 在相应的JS文件中添加功能逻辑
|
||||
3. 数据库: 如需新表,在 `models.py` 中定义
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交Issue和Pull Request!
|
||||
|
||||
---
|
||||
|
||||
**开始使用**: 运行 `python start.py` 即可开始使用!
|
||||
8
backend/.env
Normal file
8
backend/.env
Normal file
@@ -0,0 +1,8 @@
|
||||
# OpenAI API配置(可选,用于AI润色功能)
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///worklist.db
|
||||
|
||||
# Flask配置
|
||||
SECRET_KEY=your-secret-key-here
|
||||
59
backend/ai_service.py
Normal file
59
backend/ai_service.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import openai
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class AIService:
|
||||
def __init__(self):
|
||||
# 设置OpenAI API密钥
|
||||
self.api_key = os.getenv('OPENAI_API_KEY')
|
||||
# 延迟初始化客户端,避免在没有API密钥时出错
|
||||
self.client = None
|
||||
|
||||
def polish_description(self, description):
|
||||
"""
|
||||
使用AI润色任务描述
|
||||
"""
|
||||
if not description or not description.strip():
|
||||
return description
|
||||
|
||||
# 检查API密钥
|
||||
if not self.api_key or self.api_key == 'your_openai_api_key_here':
|
||||
print("AI润色功能需要配置OpenAI API密钥")
|
||||
return description
|
||||
|
||||
try:
|
||||
# 使用旧版本OpenAI API
|
||||
openai.api_key = self.api_key
|
||||
|
||||
response = openai.ChatCompletion.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的工作任务描述润色助手。请将用户提供的工作任务描述润色得更加专业、清晰、具体。保持原意不变,但让描述更加规范和易于理解。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"请润色以下工作任务描述:\n\n{description}"
|
||||
}
|
||||
],
|
||||
max_tokens=500,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
polished = response.choices[0].message.content.strip()
|
||||
return polished
|
||||
|
||||
except Exception as e:
|
||||
print(f"AI润色失败: {e}")
|
||||
# 如果AI服务失败,返回原始描述
|
||||
return description
|
||||
|
||||
def is_available(self):
|
||||
"""检查AI服务是否可用"""
|
||||
return bool(self.api_key and self.api_key != 'your_openai_api_key_here')
|
||||
|
||||
# 创建全局AI服务实例
|
||||
ai_service = AIService()
|
||||
39
backend/app.py
Normal file
39
backend/app.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from flask import Flask, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from models import db, Task, TimeRecord
|
||||
from routes import api
|
||||
import os
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# 配置
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///worklist.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
# 初始化扩展
|
||||
db.init_app(app)
|
||||
CORS(app) # 允许跨域请求
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(api, url_prefix='/api')
|
||||
|
||||
# 创建数据库表
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# 静态文件服务(用于前端)
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_from_directory('../frontend', 'index.html')
|
||||
|
||||
@app.route('/<path:filename>')
|
||||
def static_files(filename):
|
||||
return send_from_directory('../frontend', filename)
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
8
backend/env_example.txt
Normal file
8
backend/env_example.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
# OpenAI API配置
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///worklist.db
|
||||
|
||||
# Flask配置
|
||||
SECRET_KEY=your-secret-key-here
|
||||
BIN
backend/instance/worklist.db
Normal file
BIN
backend/instance/worklist.db
Normal file
Binary file not shown.
66
backend/models.py
Normal file
66
backend/models.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class Task(db.Model):
|
||||
"""任务模型"""
|
||||
__tablename__ = 'tasks'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
polished_description = db.Column(db.Text) # AI润色后的描述
|
||||
status = db.Column(db.String(20), default='pending') # pending, in_progress, completed
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关联关系
|
||||
time_records = db.relationship('TimeRecord', backref='task', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'polished_description': self.polished_description,
|
||||
'status': self.status,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'total_time': self.get_total_time()
|
||||
}
|
||||
|
||||
def get_total_time(self):
|
||||
"""获取任务总时长(秒)"""
|
||||
total_seconds = db.session.query(func.sum(TimeRecord.duration)).filter(
|
||||
TimeRecord.task_id == self.id
|
||||
).scalar() or 0
|
||||
return int(total_seconds)
|
||||
|
||||
class TimeRecord(db.Model):
|
||||
"""时间记录模型"""
|
||||
__tablename__ = 'time_records'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False)
|
||||
start_time = db.Column(db.DateTime, nullable=False)
|
||||
end_time = db.Column(db.DateTime)
|
||||
duration = db.Column(db.Integer) # 时长(秒)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'task_id': self.task_id,
|
||||
'start_time': self.start_time.isoformat() if self.start_time else None,
|
||||
'end_time': self.end_time.isoformat() if self.end_time else None,
|
||||
'duration': self.duration,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def calculate_duration(self):
|
||||
"""计算时长"""
|
||||
if self.start_time and self.end_time:
|
||||
self.duration = int((self.end_time - self.start_time).total_seconds())
|
||||
return self.duration
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==2.3.3
|
||||
Flask-CORS==4.0.0
|
||||
SQLAlchemy==2.0.21
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
python-dotenv==1.0.0
|
||||
openai==0.28.1
|
||||
408
backend/routes.py
Normal file
408
backend/routes.py
Normal file
@@ -0,0 +1,408 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
from models import db, Task, TimeRecord
|
||||
from ai_service import ai_service
|
||||
import json
|
||||
|
||||
api = Blueprint('api', __name__)
|
||||
|
||||
# 任务管理API
|
||||
@api.route('/tasks', methods=['GET'])
|
||||
def get_tasks():
|
||||
"""获取所有任务"""
|
||||
tasks = Task.query.order_by(Task.created_at.desc()).all()
|
||||
return jsonify([task.to_dict() for task in tasks])
|
||||
|
||||
@api.route('/tasks', methods=['POST'])
|
||||
def create_task():
|
||||
"""创建新任务"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'title' not in data:
|
||||
return jsonify({'error': '任务标题不能为空'}), 400
|
||||
|
||||
task = Task(
|
||||
title=data['title'],
|
||||
description=data.get('description', ''),
|
||||
status=data.get('status', 'pending')
|
||||
)
|
||||
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(task.to_dict()), 201
|
||||
|
||||
@api.route('/tasks/<int:task_id>', methods=['PUT'])
|
||||
def update_task(task_id):
|
||||
"""更新任务"""
|
||||
task = Task.query.get_or_404(task_id)
|
||||
data = request.get_json()
|
||||
|
||||
if 'title' in data:
|
||||
task.title = data['title']
|
||||
if 'description' in data:
|
||||
task.description = data['description']
|
||||
if 'status' in data:
|
||||
task.status = data['status']
|
||||
|
||||
task.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(task.to_dict())
|
||||
|
||||
@api.route('/tasks/<int:task_id>', methods=['DELETE'])
|
||||
def delete_task(task_id):
|
||||
"""删除任务"""
|
||||
task = Task.query.get_or_404(task_id)
|
||||
db.session.delete(task)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': '任务删除成功'})
|
||||
|
||||
@api.route('/tasks/<int:task_id>/polish', methods=['POST'])
|
||||
def polish_task_description(task_id):
|
||||
"""AI润色任务描述"""
|
||||
task = Task.query.get_or_404(task_id)
|
||||
|
||||
if not ai_service.is_available():
|
||||
return jsonify({'error': 'AI服务不可用,请检查API密钥配置'}), 500
|
||||
|
||||
if not task.description:
|
||||
return jsonify({'error': '任务描述为空,无法润色'}), 400
|
||||
|
||||
polished_description = ai_service.polish_description(task.description)
|
||||
task.polished_description = polished_description
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'original': task.description,
|
||||
'polished': polished_description
|
||||
})
|
||||
|
||||
# 计时器API
|
||||
@api.route('/timer/start', methods=['POST'])
|
||||
def start_timer():
|
||||
"""开始计时"""
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
|
||||
if not task_id:
|
||||
return jsonify({'error': '任务ID不能为空'}), 400
|
||||
|
||||
task = Task.query.get_or_404(task_id)
|
||||
|
||||
# 检查是否已有进行中的计时
|
||||
active_record = TimeRecord.query.filter_by(
|
||||
task_id=task_id,
|
||||
end_time=None
|
||||
).first()
|
||||
|
||||
if active_record:
|
||||
return jsonify({'error': '该任务已在计时中'}), 400
|
||||
|
||||
# 创建新的时间记录
|
||||
time_record = TimeRecord(
|
||||
task_id=task_id,
|
||||
start_time=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.session.add(time_record)
|
||||
task.status = 'in_progress'
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(time_record.to_dict())
|
||||
|
||||
@api.route('/timer/stop', methods=['POST'])
|
||||
def stop_timer():
|
||||
"""停止计时"""
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
|
||||
if not task_id:
|
||||
return jsonify({'error': '任务ID不能为空'}), 400
|
||||
|
||||
# 查找进行中的时间记录
|
||||
time_record = TimeRecord.query.filter_by(
|
||||
task_id=task_id,
|
||||
end_time=None
|
||||
).first()
|
||||
|
||||
if not time_record:
|
||||
return jsonify({'error': '没有找到进行中的计时'}), 400
|
||||
|
||||
# 结束计时
|
||||
time_record.end_time = datetime.utcnow()
|
||||
time_record.calculate_duration()
|
||||
|
||||
# 更新任务状态
|
||||
task = Task.query.get(task_id)
|
||||
if task:
|
||||
task.status = 'pending' # 或者根据业务逻辑设置其他状态
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(time_record.to_dict())
|
||||
|
||||
@api.route('/timer/status/<int:task_id>', methods=['GET'])
|
||||
def get_timer_status(task_id):
|
||||
"""获取任务计时状态"""
|
||||
active_record = TimeRecord.query.filter_by(
|
||||
task_id=task_id,
|
||||
end_time=None
|
||||
).first()
|
||||
|
||||
if active_record:
|
||||
return jsonify({
|
||||
'is_running': True,
|
||||
'start_time': active_record.start_time.isoformat(),
|
||||
'duration': int((datetime.utcnow() - active_record.start_time).total_seconds())
|
||||
})
|
||||
else:
|
||||
return jsonify({'is_running': False})
|
||||
|
||||
@api.route('/timer/status/batch', methods=['POST'])
|
||||
def get_timer_status_batch():
|
||||
"""批量获取任务计时状态"""
|
||||
data = request.get_json() or {}
|
||||
task_ids = data.get('task_ids', [])
|
||||
|
||||
if not isinstance(task_ids, list) or not task_ids:
|
||||
return jsonify({'error': '任务ID列表不能为空'}), 400
|
||||
|
||||
# 初始化默认状态
|
||||
statuses = {int(task_id): {'is_running': False} for task_id in task_ids}
|
||||
|
||||
active_records = TimeRecord.query.filter(
|
||||
TimeRecord.task_id.in_(task_ids),
|
||||
TimeRecord.end_time.is_(None)
|
||||
).all()
|
||||
|
||||
now = datetime.utcnow()
|
||||
for record in active_records:
|
||||
statuses[record.task_id] = {
|
||||
'is_running': True,
|
||||
'start_time': record.start_time.isoformat(),
|
||||
'duration': int((now - record.start_time).total_seconds())
|
||||
}
|
||||
|
||||
return jsonify(statuses)
|
||||
# 统计报表API
|
||||
@api.route('/reports/daily', methods=['GET'])
|
||||
def get_daily_report():
|
||||
"""获取日报表"""
|
||||
date_str = request.args.get('date')
|
||||
if date_str:
|
||||
try:
|
||||
target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return jsonify({'error': '日期格式错误,请使用YYYY-MM-DD格式'}), 400
|
||||
else:
|
||||
target_date = datetime.now().date()
|
||||
|
||||
# 获取指定日期的所有时间记录
|
||||
start_datetime = datetime.combine(target_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(target_date, datetime.max.time())
|
||||
|
||||
records = TimeRecord.query.filter(
|
||||
TimeRecord.start_time >= start_datetime,
|
||||
TimeRecord.start_time <= end_datetime,
|
||||
TimeRecord.end_time.isnot(None)
|
||||
).all()
|
||||
|
||||
# 按任务分组统计
|
||||
task_stats = {}
|
||||
total_time = 0
|
||||
|
||||
for record in records:
|
||||
task_id = record.task_id
|
||||
if task_id not in task_stats:
|
||||
task = Task.query.get(task_id)
|
||||
task_stats[task_id] = {
|
||||
'task': task.to_dict() if task else None,
|
||||
'total_duration': 0,
|
||||
'records': []
|
||||
}
|
||||
|
||||
task_stats[task_id]['total_duration'] += record.duration or 0
|
||||
task_stats[task_id]['records'].append(record.to_dict())
|
||||
total_time += record.duration or 0
|
||||
|
||||
return jsonify({
|
||||
'date': target_date.isoformat(),
|
||||
'total_time': total_time,
|
||||
'task_stats': list(task_stats.values())
|
||||
})
|
||||
|
||||
@api.route('/reports/summary', methods=['GET'])
|
||||
def get_summary_report():
|
||||
"""获取汇总报表"""
|
||||
days = int(request.args.get('days', 7)) # 默认最近7天
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
start_datetime = datetime.combine(start_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(end_date, datetime.max.time())
|
||||
|
||||
# 获取时间范围内的记录
|
||||
records = TimeRecord.query.filter(
|
||||
TimeRecord.start_time >= start_datetime,
|
||||
TimeRecord.start_time <= end_datetime,
|
||||
TimeRecord.end_time.isnot(None)
|
||||
).all()
|
||||
|
||||
# 按日期分组
|
||||
daily_stats = {}
|
||||
for record in records:
|
||||
date_key = record.start_time.date().isoformat()
|
||||
if date_key not in daily_stats:
|
||||
daily_stats[date_key] = {
|
||||
'date': date_key,
|
||||
'total_time': 0,
|
||||
'tasks': {}
|
||||
}
|
||||
|
||||
task_id = record.task_id
|
||||
if task_id not in daily_stats[date_key]['tasks']:
|
||||
task = Task.query.get(task_id)
|
||||
daily_stats[date_key]['tasks'][task_id] = {
|
||||
'task_title': task.title if task else f'任务{task_id}',
|
||||
'total_time': 0
|
||||
}
|
||||
|
||||
duration = record.duration or 0
|
||||
daily_stats[date_key]['total_time'] += duration
|
||||
daily_stats[date_key]['tasks'][task_id]['total_time'] += duration
|
||||
|
||||
return jsonify({
|
||||
'period': f'{start_date.isoformat()} 至 {end_date.isoformat()}',
|
||||
'daily_stats': list(daily_stats.values())
|
||||
})
|
||||
|
||||
# 时间段历史API
|
||||
@api.route('/tasks/<int:task_id>/time-history', methods=['GET'])
|
||||
def get_task_time_history(task_id):
|
||||
"""获取任务的时间段历史"""
|
||||
task = Task.query.get_or_404(task_id)
|
||||
|
||||
# 获取参数
|
||||
days = int(request.args.get('days', 30)) # 默认最近30天
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 20))
|
||||
|
||||
# 计算日期范围
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
start_datetime = datetime.combine(start_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(end_date, datetime.max.time())
|
||||
|
||||
# 查询时间记录
|
||||
query = TimeRecord.query.filter(
|
||||
TimeRecord.task_id == task_id,
|
||||
TimeRecord.start_time >= start_datetime,
|
||||
TimeRecord.start_time <= end_datetime,
|
||||
TimeRecord.end_time.isnot(None)
|
||||
).order_by(TimeRecord.start_time.desc())
|
||||
|
||||
# 分页
|
||||
pagination = query.paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# 按日期分组
|
||||
daily_segments = {}
|
||||
for record in pagination.items:
|
||||
date_key = record.start_time.date().isoformat()
|
||||
if date_key not in daily_segments:
|
||||
daily_segments[date_key] = {
|
||||
'date': date_key,
|
||||
'total_duration': 0,
|
||||
'segments': []
|
||||
}
|
||||
|
||||
daily_segments[date_key]['total_duration'] += record.duration or 0
|
||||
daily_segments[date_key]['segments'].append(record.to_dict())
|
||||
|
||||
return jsonify({
|
||||
'task': task.to_dict(),
|
||||
'period': f'{start_date.isoformat()} 至 {end_date.isoformat()}',
|
||||
'daily_segments': list(daily_segments.values()),
|
||||
'pagination': {
|
||||
'page': pagination.page,
|
||||
'pages': pagination.pages,
|
||||
'per_page': pagination.per_page,
|
||||
'total': pagination.total,
|
||||
'has_next': pagination.has_next,
|
||||
'has_prev': pagination.has_prev
|
||||
}
|
||||
})
|
||||
|
||||
@api.route('/time-history', methods=['GET'])
|
||||
def get_all_time_history():
|
||||
"""获取所有任务的时间段历史"""
|
||||
# 获取参数
|
||||
days = int(request.args.get('days', 7)) # 默认最近7天
|
||||
task_id = request.args.get('task_id') # 可选的任务ID过滤
|
||||
|
||||
# 计算日期范围
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
start_datetime = datetime.combine(start_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(end_date, datetime.max.time())
|
||||
|
||||
# 构建查询
|
||||
query = TimeRecord.query.filter(
|
||||
TimeRecord.start_time >= start_datetime,
|
||||
TimeRecord.start_time <= end_datetime,
|
||||
TimeRecord.end_time.isnot(None)
|
||||
)
|
||||
|
||||
if task_id:
|
||||
query = query.filter(TimeRecord.task_id == task_id)
|
||||
|
||||
# 按开始时间排序
|
||||
records = query.order_by(TimeRecord.start_time.desc()).all()
|
||||
|
||||
# 按日期和任务分组
|
||||
daily_tasks = {}
|
||||
for record in records:
|
||||
date_key = record.start_time.date().isoformat()
|
||||
task_id = record.task_id
|
||||
|
||||
if date_key not in daily_tasks:
|
||||
daily_tasks[date_key] = {}
|
||||
|
||||
if task_id not in daily_tasks[date_key]:
|
||||
task = Task.query.get(task_id)
|
||||
daily_tasks[date_key][task_id] = {
|
||||
'task': task.to_dict() if task else None,
|
||||
'total_duration': 0,
|
||||
'segments': []
|
||||
}
|
||||
|
||||
daily_tasks[date_key][task_id]['total_duration'] += record.duration or 0
|
||||
daily_tasks[date_key][task_id]['segments'].append(record.to_dict())
|
||||
|
||||
# 转换为列表格式
|
||||
result = []
|
||||
for date, tasks in daily_tasks.items():
|
||||
day_data = {
|
||||
'date': date,
|
||||
'total_time': sum(task['total_duration'] for task in tasks.values()),
|
||||
'tasks': list(tasks.values())
|
||||
}
|
||||
result.append(day_data)
|
||||
|
||||
# 按日期排序(最新的在前)
|
||||
result.sort(key=lambda x: x['date'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'period': f'{start_date.isoformat()} 至 {end_date.isoformat()}',
|
||||
'daily_tasks': result
|
||||
})
|
||||
814
frontend/css/style.css
Normal file
814
frontend/css/style.css
Normal file
@@ -0,0 +1,814 @@
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 20px 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #4a5568;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header h1 i {
|
||||
color: #667eea;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f7fafc;
|
||||
color: #4a5568;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #edf2f7;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #38a169;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f56565;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #e53e3e;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 30px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 任务区域 */
|
||||
.task-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.task-section h2 {
|
||||
color: #4a5568;
|
||||
margin-bottom: 25px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-exclusions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
background: rgba(103, 126, 234, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.exclusion-label {
|
||||
color: #4a5568;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.35);
|
||||
}
|
||||
|
||||
.exclusion-btn {
|
||||
min-width: 80px;
|
||||
border-color: #cbd5e0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.exclusion-btn.active {
|
||||
background: #f56565;
|
||||
border-color: #f56565;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 6px 20px rgba(245, 101, 101, 0.3);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 任务项样式 */
|
||||
.task-item {
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.task-item.in-progress {
|
||||
border-color: #48bb78;
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #e6fffa 100%);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
border-color: #a0aec0;
|
||||
background: #f7fafc;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-status.pending {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.task-status.in-progress {
|
||||
background: #c6f6d5;
|
||||
color: #2f855a;
|
||||
}
|
||||
|
||||
.task-status.completed {
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
color: #718096;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.task-description.polished {
|
||||
background: #f0f9ff;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #2d3748;
|
||||
background: #f7fafc;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 统计面板 */
|
||||
.stats-panel {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.stats-panel h3 {
|
||||
color: #4a5568;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 15px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: modalSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content.large {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 25px 30px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: #2d3748;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #a0aec0;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 30px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #4a5568;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.polished-content {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bee3f8;
|
||||
}
|
||||
|
||||
.polished-content h4 {
|
||||
color: #2b6cb0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.polished-text {
|
||||
color: #2d3748;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.polished-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 报表样式 */
|
||||
.report-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: end;
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 历史记录样式 */
|
||||
.history-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: end;
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.history-content {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-day {
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.history-day h4 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.day-total-time {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.history-task {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.history-task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.history-task-title {
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.history-task-total {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.time-segments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-segment {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 15px;
|
||||
background: #f7fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.time-segment:hover {
|
||||
background: #edf2f7;
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.segment-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segment-duration {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.segment-period {
|
||||
color: #718096;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.segment-timespan {
|
||||
color: #4a5568;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.history-empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.history-empty i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-empty h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-empty p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.report-content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.report-day {
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.report-day h4 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.report-task {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.report-task:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.report-task-title {
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.report-task-time {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 加载提示 */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-spinner span {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 10% auto;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.report-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
229
frontend/index.html
Normal file
229
frontend/index.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>工作任务管理系统</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 头部 -->
|
||||
<header class="header">
|
||||
<h1><i class="fas fa-tasks"></i> 工作任务管理系统</h1>
|
||||
<div class="header-actions">
|
||||
<button id="addTaskBtn" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 添加任务
|
||||
</button>
|
||||
<button id="reportBtn" class="btn btn-secondary">
|
||||
<i class="fas fa-chart-bar"></i> 查看报表
|
||||
</button>
|
||||
<button id="timeHistoryBtn" class="btn btn-secondary">
|
||||
<i class="fas fa-history"></i> 时间历史
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 任务列表 -->
|
||||
<section class="task-section">
|
||||
<h2>任务列表</h2>
|
||||
<div class="task-filters" role="group" aria-label="任务状态筛选">
|
||||
<button type="button" class="btn btn-secondary filter-btn active" data-filter="all">
|
||||
<i class="fas fa-globe"></i> 全部
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary filter-btn" data-filter="pending">
|
||||
<i class="fas fa-circle-notch"></i> 未开始
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary filter-btn" data-filter="in_progress">
|
||||
<i class="fas fa-play-circle"></i> 进行中
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary filter-btn" data-filter="completed">
|
||||
<i class="fas fa-check-circle"></i> 已完成
|
||||
</button>
|
||||
</div>
|
||||
<div class="task-exclusions" role="group" aria-label="排除任务状态">
|
||||
<span class="exclusion-label">
|
||||
<i class="fas fa-ban"></i> 排除状态:
|
||||
</span>
|
||||
<button type="button" class="btn btn-outline exclusion-btn" data-status="pending">
|
||||
未开始
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline exclusion-btn" data-status="in_progress">
|
||||
进行中
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline exclusion-btn" data-status="completed">
|
||||
已完成
|
||||
</button>
|
||||
</div>
|
||||
<div id="taskList" class="task-list">
|
||||
<!-- 任务项将通过JavaScript动态生成 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 时间统计面板 -->
|
||||
<aside class="stats-panel">
|
||||
<h3>今日统计</h3>
|
||||
<div class="stats-card">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总时长</span>
|
||||
<span id="totalTime" class="stat-value">00:00:00</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">完成任务</span>
|
||||
<span id="completedTasks" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">进行中</span>
|
||||
<span id="activeTasks" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑任务模态框 -->
|
||||
<div id="taskModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">添加任务</h3>
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="taskForm">
|
||||
<div class="form-group">
|
||||
<label for="taskTitle">任务标题 *</label>
|
||||
<input type="text" id="taskTitle" name="title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="taskDescription">任务描述</label>
|
||||
<textarea id="taskDescription" name="description" rows="4" placeholder="描述任务的具体内容和要求..."></textarea>
|
||||
<div class="form-actions">
|
||||
<button type="button" id="polishBtn" class="btn btn-outline">
|
||||
<i class="fas fa-magic"></i> AI润色
|
||||
</button>
|
||||
</div>
|
||||
<div id="polishedDescription" class="polished-content" style="display: none;">
|
||||
<h4>润色后的描述:</h4>
|
||||
<div class="polished-text"></div>
|
||||
<div class="polished-actions">
|
||||
<button type="button" id="usePolishedBtn" class="btn btn-primary btn-sm">使用润色版本</button>
|
||||
<button type="button" id="discardPolishedBtn" class="btn btn-outline btn-sm">保持原版</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="taskStatus">状态</label>
|
||||
<select id="taskStatus" name="status">
|
||||
<option value="pending">待开始</option>
|
||||
<option value="in_progress">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="cancelBtn" class="btn btn-outline">取消</button>
|
||||
<button type="button" id="saveBtn" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报表模态框 -->
|
||||
<div id="reportModal" class="modal">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h3>时间统计报表</h3>
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="report-controls">
|
||||
<div class="form-group">
|
||||
<label for="reportDate">选择日期</label>
|
||||
<input type="date" id="reportDate">
|
||||
</div>
|
||||
<button id="loadReportBtn" class="btn btn-primary">加载报表</button>
|
||||
</div>
|
||||
<div id="reportContent" class="report-content">
|
||||
<!-- 报表内容将通过JavaScript动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间历史模态框 -->
|
||||
<div id="timeHistoryModal" class="modal">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h3>时间历史记录</h3>
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="history-controls">
|
||||
<div class="form-group">
|
||||
<label for="historyDays">查看天数</label>
|
||||
<select id="historyDays">
|
||||
<option value="7">最近7天</option>
|
||||
<option value="14">最近14天</option>
|
||||
<option value="30">最近30天</option>
|
||||
<option value="90">最近90天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="historyTaskFilter">任务筛选</label>
|
||||
<select id="historyTaskFilter">
|
||||
<option value="">所有任务</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="loadHistoryBtn" class="btn btn-primary">加载历史</button>
|
||||
</div>
|
||||
<div id="timeHistoryContent" class="history-content">
|
||||
<!-- 时间历史内容将通过JavaScript动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务时间历史模态框 -->
|
||||
<div id="taskTimeHistoryModal" class="modal">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h3 id="taskHistoryTitle">任务时间历史</h3>
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="history-controls">
|
||||
<div class="form-group">
|
||||
<label for="taskHistoryDays">查看天数</label>
|
||||
<select id="taskHistoryDays">
|
||||
<option value="7">最近7天</option>
|
||||
<option value="14">最近14天</option>
|
||||
<option value="30">最近30天</option>
|
||||
<option value="90">最近90天</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="loadTaskHistoryBtn" class="btn btn-primary">加载历史</button>
|
||||
</div>
|
||||
<div id="taskTimeHistoryContent" class="history-content">
|
||||
<!-- 任务时间历史内容将通过JavaScript动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载提示 -->
|
||||
<div id="loadingOverlay" class="loading-overlay" style="display: none;">
|
||||
<div class="loading-spinner">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript文件 -->
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/timer.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
115
frontend/js/api.js
Normal file
115
frontend/js/api.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// API调用模块
|
||||
class WorkListAPI {
|
||||
constructor() {
|
||||
this.baseURL = '/api';
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP错误: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 任务管理API
|
||||
async getTasks() {
|
||||
return this.request('/tasks');
|
||||
}
|
||||
|
||||
async createTask(taskData) {
|
||||
return this.request('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
}
|
||||
|
||||
async updateTask(taskId, taskData) {
|
||||
return this.request(`/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTask(taskId) {
|
||||
return this.request(`/tasks/${taskId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async polishTaskDescription(taskId) {
|
||||
return this.request(`/tasks/${taskId}/polish`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
// 计时器API
|
||||
async startTimer(taskId) {
|
||||
return this.request('/timer/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId })
|
||||
});
|
||||
}
|
||||
|
||||
async stopTimer(taskId) {
|
||||
return this.request('/timer/stop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId })
|
||||
});
|
||||
}
|
||||
|
||||
async getTimerStatus(taskId) {
|
||||
return this.request(`/timer/status/${taskId}`);
|
||||
}
|
||||
|
||||
async getTimerStatuses(taskIds) {
|
||||
return this.request('/timer/status/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_ids: taskIds })
|
||||
});
|
||||
}
|
||||
|
||||
// 报表API
|
||||
async getDailyReport(date) {
|
||||
const params = date ? `?date=${date}` : '';
|
||||
return this.request(`/reports/daily${params}`);
|
||||
}
|
||||
|
||||
async getSummaryReport(days = 7) {
|
||||
return this.request(`/reports/summary?days=${days}`);
|
||||
}
|
||||
|
||||
// 时间段历史API
|
||||
async getTaskTimeHistory(taskId, days = 30, page = 1, perPage = 20) {
|
||||
return this.request(`/tasks/${taskId}/time-history?days=${days}&page=${page}&per_page=${perPage}`);
|
||||
}
|
||||
|
||||
async getAllTimeHistory(days = 7, taskId = null) {
|
||||
const params = new URLSearchParams({ days: days });
|
||||
if (taskId) {
|
||||
params.append('task_id', taskId);
|
||||
}
|
||||
return this.request(`/time-history?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局API实例
|
||||
const api = new WorkListAPI();
|
||||
807
frontend/js/app.js
Normal file
807
frontend/js/app.js
Normal file
@@ -0,0 +1,807 @@
|
||||
// 主应用类
|
||||
class WorkListApp {
|
||||
constructor() {
|
||||
this.tasks = [];
|
||||
this.currentEditingTask = null;
|
||||
this.currentFilter = 'all';
|
||||
this.excludedStatuses = new Set();
|
||||
this.init();
|
||||
}
|
||||
|
||||
// 初始化应用
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadTasks();
|
||||
this.setupDatePicker();
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
bindEvents() {
|
||||
// 添加任务按钮
|
||||
document.getElementById('addTaskBtn').addEventListener('click', () => {
|
||||
this.showTaskModal();
|
||||
});
|
||||
|
||||
// 报表按钮
|
||||
document.getElementById('reportBtn').addEventListener('click', () => {
|
||||
this.showReportModal();
|
||||
});
|
||||
|
||||
// 时间历史按钮
|
||||
document.getElementById('timeHistoryBtn').addEventListener('click', () => {
|
||||
this.showTimeHistoryModal();
|
||||
});
|
||||
|
||||
// 模态框关闭
|
||||
document.querySelectorAll('.close').forEach(closeBtn => {
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
const modal = e.target.closest('.modal');
|
||||
this.hideModal(modal);
|
||||
});
|
||||
});
|
||||
|
||||
// 点击模态框外部关闭
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.hideModal(modal);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 任务表单
|
||||
document.getElementById('saveBtn').addEventListener('click', () => {
|
||||
this.saveTask();
|
||||
});
|
||||
|
||||
document.getElementById('cancelBtn').addEventListener('click', () => {
|
||||
this.hideModal(document.getElementById('taskModal'));
|
||||
});
|
||||
|
||||
// AI润色按钮
|
||||
document.getElementById('polishBtn').addEventListener('click', () => {
|
||||
this.polishDescription();
|
||||
});
|
||||
|
||||
// 润色相关按钮
|
||||
document.getElementById('usePolishedBtn').addEventListener('click', () => {
|
||||
this.usePolishedDescription();
|
||||
});
|
||||
|
||||
document.getElementById('discardPolishedBtn').addEventListener('click', () => {
|
||||
this.discardPolishedDescription();
|
||||
});
|
||||
|
||||
// 报表加载按钮
|
||||
document.getElementById('loadReportBtn').addEventListener('click', () => {
|
||||
this.loadReport();
|
||||
});
|
||||
|
||||
// 历史记录加载按钮
|
||||
document.getElementById('loadHistoryBtn').addEventListener('click', () => {
|
||||
this.loadTimeHistory();
|
||||
});
|
||||
|
||||
// 任务历史加载按钮
|
||||
document.getElementById('loadTaskHistoryBtn').addEventListener('click', () => {
|
||||
this.loadTaskTimeHistory();
|
||||
});
|
||||
|
||||
// 任务筛选按钮
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (event) => {
|
||||
const filter = event.currentTarget.dataset.filter;
|
||||
this.setTaskFilter(filter);
|
||||
});
|
||||
});
|
||||
|
||||
// 排除状态按钮
|
||||
document.querySelectorAll('.exclusion-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (event) => {
|
||||
const status = event.currentTarget.dataset.status;
|
||||
this.toggleExcludeStatus(status);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 设置日期选择器
|
||||
setupDatePicker() {
|
||||
const dateInput = document.getElementById('reportDate');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
dateInput.value = today;
|
||||
}
|
||||
|
||||
// 显示加载提示
|
||||
showLoading() {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// 隐藏加载提示
|
||||
hideLoading() {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// 显示模态框
|
||||
showModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
modal.style.display = 'block';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// 隐藏模态框
|
||||
hideModal(modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
// 显示任务模态框
|
||||
showTaskModal(task = null) {
|
||||
this.currentEditingTask = task;
|
||||
const modal = document.getElementById('taskModal');
|
||||
const form = document.getElementById('taskForm');
|
||||
|
||||
if (task) {
|
||||
// 编辑模式
|
||||
document.getElementById('modalTitle').textContent = '编辑任务';
|
||||
document.getElementById('taskTitle').value = task.title;
|
||||
document.getElementById('taskDescription').value = task.description || '';
|
||||
document.getElementById('taskStatus').value = task.status;
|
||||
} else {
|
||||
// 新建模式
|
||||
document.getElementById('modalTitle').textContent = '添加任务';
|
||||
form.reset();
|
||||
}
|
||||
|
||||
// 隐藏润色内容
|
||||
document.getElementById('polishedDescription').style.display = 'none';
|
||||
|
||||
this.showModal('taskModal');
|
||||
}
|
||||
|
||||
// 保存任务
|
||||
async saveTask() {
|
||||
try {
|
||||
this.showLoading();
|
||||
|
||||
const formData = {
|
||||
title: document.getElementById('taskTitle').value.trim(),
|
||||
description: document.getElementById('taskDescription').value.trim(),
|
||||
status: document.getElementById('taskStatus').value
|
||||
};
|
||||
|
||||
if (!formData.title) {
|
||||
throw new Error('任务标题不能为空');
|
||||
}
|
||||
|
||||
let savedTask;
|
||||
if (this.currentEditingTask) {
|
||||
// 更新任务
|
||||
savedTask = await api.updateTask(this.currentEditingTask.id, formData);
|
||||
} else {
|
||||
// 创建任务
|
||||
savedTask = await api.createTask(formData);
|
||||
}
|
||||
|
||||
this.hideModal(document.getElementById('taskModal'));
|
||||
await this.loadTasks();
|
||||
this.showNotification('任务保存成功', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('保存任务失败:', error);
|
||||
this.showNotification(error.message || '保存任务失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
async deleteTask(taskId) {
|
||||
if (!confirm('确定要删除这个任务吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.showLoading();
|
||||
await api.deleteTask(taskId);
|
||||
await this.loadTasks();
|
||||
this.showNotification('任务删除成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error);
|
||||
this.showNotification(error.message || '删除任务失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 开始计时
|
||||
async startTimer(taskId) {
|
||||
try {
|
||||
this.showLoading();
|
||||
await taskTimer.startTimer(taskId);
|
||||
this.updateTaskStatusLocal(taskId, 'in_progress');
|
||||
this.renderTasks();
|
||||
this.updateStats();
|
||||
this.showNotification('计时开始', 'success');
|
||||
} catch (error) {
|
||||
console.error('开始计时失败:', error);
|
||||
this.showNotification(error.message || '开始计时失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 停止计时
|
||||
async stopTimer(taskId) {
|
||||
try {
|
||||
this.showLoading();
|
||||
await taskTimer.stopTimer(taskId);
|
||||
this.updateTaskStatusLocal(taskId, 'pending');
|
||||
this.renderTasks();
|
||||
this.updateStats();
|
||||
this.showNotification('计时结束', 'success');
|
||||
} catch (error) {
|
||||
console.error('停止计时失败:', error);
|
||||
this.showNotification(error.message || '停止计时失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// AI润色描述
|
||||
async polishDescription() {
|
||||
const description = document.getElementById('taskDescription').value.trim();
|
||||
if (!description) {
|
||||
this.showNotification('请先输入任务描述', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.showLoading();
|
||||
|
||||
// 如果有正在编辑的任务,使用其ID;否则提示用户先保存任务
|
||||
if (!this.currentEditingTask) {
|
||||
this.showNotification('请先保存任务,然后再进行润色', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.polishTaskDescription(this.currentEditingTask.id);
|
||||
|
||||
// 显示润色结果
|
||||
const polishedDiv = document.getElementById('polishedDescription');
|
||||
const polishedText = polishedDiv.querySelector('.polished-text');
|
||||
polishedText.textContent = result.polished;
|
||||
polishedDiv.style.display = 'block';
|
||||
|
||||
this.showNotification('AI润色完成', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI润色失败:', error);
|
||||
this.showNotification(error.message || 'AI润色失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 使用润色版本
|
||||
usePolishedDescription() {
|
||||
const polishedText = document.querySelector('.polished-text').textContent;
|
||||
document.getElementById('taskDescription').value = polishedText;
|
||||
document.getElementById('polishedDescription').style.display = 'none';
|
||||
this.showNotification('已使用润色版本', 'success');
|
||||
}
|
||||
|
||||
// 丢弃润色版本
|
||||
discardPolishedDescription() {
|
||||
document.getElementById('polishedDescription').style.display = 'none';
|
||||
}
|
||||
|
||||
// 加载任务列表
|
||||
async loadTasks() {
|
||||
try {
|
||||
this.tasks = await api.getTasks();
|
||||
// 恢复未结束的计时器(需要在渲染之前)
|
||||
await taskTimer.restoreTimers(this.tasks);
|
||||
this.renderTasks();
|
||||
this.updateStats();
|
||||
} catch (error) {
|
||||
console.error('加载任务失败:', error);
|
||||
this.showNotification('加载任务失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 设置任务状态筛选
|
||||
setTaskFilter(filter = 'all') {
|
||||
const normalized = filter || 'all';
|
||||
if (this.currentFilter !== normalized) {
|
||||
this.currentFilter = normalized;
|
||||
}
|
||||
this.renderTasks();
|
||||
}
|
||||
|
||||
// 更新筛选按钮状态
|
||||
updateFilterButtons() {
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.filter === this.currentFilter;
|
||||
btn.classList.toggle('active', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
// 切换排除状态
|
||||
toggleExcludeStatus(status) {
|
||||
if (this.excludedStatuses.has(status)) {
|
||||
this.excludedStatuses.delete(status);
|
||||
} else {
|
||||
this.excludedStatuses.add(status);
|
||||
}
|
||||
this.renderTasks();
|
||||
}
|
||||
|
||||
// 更新排除按钮状态
|
||||
updateExclusionButtons() {
|
||||
document.querySelectorAll('.exclusion-btn').forEach(btn => {
|
||||
const status = btn.dataset.status;
|
||||
btn.classList.toggle('active', this.excludedStatuses.has(status));
|
||||
});
|
||||
}
|
||||
|
||||
// 获取筛选后的任务列表
|
||||
getFilteredTasks() {
|
||||
if (this.currentFilter === 'all') {
|
||||
return this.tasks.filter(task => !this.excludedStatuses.has(task.status));
|
||||
}
|
||||
return this.tasks
|
||||
.filter(task => task.status === this.currentFilter)
|
||||
.filter(task => !this.excludedStatuses.has(task.status));
|
||||
}
|
||||
|
||||
// 获取筛选名称
|
||||
getFilterDisplayName(filter) {
|
||||
const map = {
|
||||
'all': '全部任务',
|
||||
'pending': '未开始任务',
|
||||
'in_progress': '进行中任务',
|
||||
'completed': '已完成任务'
|
||||
};
|
||||
return map[filter] || '任务';
|
||||
}
|
||||
|
||||
// 渲染任务列表
|
||||
renderTasks() {
|
||||
const taskList = document.getElementById('taskList');
|
||||
this.updateFilterButtons();
|
||||
this.updateExclusionButtons();
|
||||
const filteredTasks = this.getFilteredTasks();
|
||||
|
||||
if (this.tasks.length === 0) {
|
||||
taskList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-tasks"></i>
|
||||
<h3>暂无任务</h3>
|
||||
<p>点击"添加任务"开始记录你的工作</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredTasks.length === 0) {
|
||||
const filterLabel = this.getFilterDisplayName(this.currentFilter);
|
||||
taskList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-filter"></i>
|
||||
<h3>暂无${filterLabel}</h3>
|
||||
<p>试试切换其他状态,或者新建一条任务</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
taskList.innerHTML = filteredTasks.map(task => this.renderTaskItem(task)).join('');
|
||||
}
|
||||
|
||||
// 渲染单个任务项
|
||||
renderTaskItem(task) {
|
||||
const statusClass = task.status;
|
||||
const statusText = this.getStatusText(task.status);
|
||||
const isRunning = taskTimer.hasActiveTimer(task.id);
|
||||
|
||||
// 如果正在计时,显示当前会话时长;否则显示总时长
|
||||
let displayTime;
|
||||
if (isRunning) {
|
||||
const timer = taskTimer.activeTimers.get(task.id);
|
||||
const currentDuration = timer ? timer.currentSessionDuration || 0 : 0;
|
||||
displayTime = this.formatTime(currentDuration);
|
||||
} else {
|
||||
displayTime = this.formatTime(task.total_time || 0);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="task-item ${statusClass} fade-in" data-task-id="${task.id}">
|
||||
<div class="task-header">
|
||||
<div>
|
||||
<div class="task-title">${this.escapeHtml(task.title)}</div>
|
||||
<div class="task-status ${statusClass}">${statusText}</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="btn btn-outline btn-sm" onclick="app.showTaskModal(${JSON.stringify(task).replace(/"/g, '"')})">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="app.showTaskTimeHistory(${task.id})">
|
||||
<i class="fas fa-clock"></i> 时间历史
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="app.deleteTask(${task.id})">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${task.description ? `
|
||||
<div class="task-description ${task.polished_description ? 'polished' : ''}">
|
||||
${this.escapeHtml(task.description)}
|
||||
${task.polished_description ? `
|
||||
<div class="polished-version">
|
||||
<strong>润色版本:</strong>
|
||||
${this.escapeHtml(task.polished_description)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="task-timer">
|
||||
<div class="timer-display">${displayTime}</div>
|
||||
<div class="timer-controls">
|
||||
<button class="btn btn-success btn-sm start-timer-btn"
|
||||
onclick="app.startTimer(${task.id})"
|
||||
style="display: ${isRunning ? 'none' : 'inline-flex'}">
|
||||
<i class="fas fa-play"></i> 开始
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm stop-timer-btn"
|
||||
onclick="app.stopTimer(${task.id})"
|
||||
style="display: ${isRunning ? 'inline-flex' : 'none'}">
|
||||
<i class="fas fa-stop"></i> 停止
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-stats">
|
||||
<div class="stat-item">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>总时长: ${this.formatTime(task.total_time || 0)}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<i class="fas fa-calendar"></i>
|
||||
<span>创建: ${this.formatDate(task.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 更新本地任务状态
|
||||
updateTaskStatusLocal(taskId, status) {
|
||||
const task = this.tasks.find(item => item.id === taskId);
|
||||
if (task) {
|
||||
task.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
updateStats() {
|
||||
const totalTime = this.tasks.reduce((sum, task) => sum + (task.total_time || 0), 0);
|
||||
const completedTasks = this.tasks.filter(task => task.status === 'completed').length;
|
||||
const activeTasks = this.tasks.filter(task => task.status === 'in_progress').length;
|
||||
|
||||
document.getElementById('totalTime').textContent = this.formatTime(totalTime);
|
||||
document.getElementById('completedTasks').textContent = completedTasks;
|
||||
document.getElementById('activeTasks').textContent = activeTasks;
|
||||
}
|
||||
|
||||
// 显示报表模态框
|
||||
showReportModal() {
|
||||
this.showModal('reportModal');
|
||||
this.loadReport();
|
||||
}
|
||||
|
||||
// 显示时间历史模态框
|
||||
showTimeHistoryModal() {
|
||||
this.showModal('timeHistoryModal');
|
||||
this.loadTaskFilter();
|
||||
this.loadTimeHistory();
|
||||
}
|
||||
|
||||
// 显示任务时间历史模态框
|
||||
showTaskTimeHistory(taskId) {
|
||||
this.currentViewingTaskId = taskId;
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
document.getElementById('taskHistoryTitle').textContent = `"${task.title}" 的时间历史`;
|
||||
}
|
||||
this.showModal('taskTimeHistoryModal');
|
||||
this.loadTaskTimeHistory();
|
||||
}
|
||||
|
||||
// 加载报表
|
||||
async loadReport() {
|
||||
try {
|
||||
this.showLoading();
|
||||
const date = document.getElementById('reportDate').value;
|
||||
const report = await api.getDailyReport(date);
|
||||
this.renderReport(report);
|
||||
} catch (error) {
|
||||
console.error('加载报表失败:', error);
|
||||
this.showNotification('加载报表失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染报表
|
||||
renderReport(report) {
|
||||
const reportContent = document.getElementById('reportContent');
|
||||
|
||||
if (report.task_stats.length === 0) {
|
||||
reportContent.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
<h3>暂无数据</h3>
|
||||
<p>${report.date} 没有记录任何工作时间</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const totalTime = this.formatTime(report.total_time);
|
||||
|
||||
reportContent.innerHTML = `
|
||||
<div class="report-day">
|
||||
<h4>${report.date} - 总工作时长: ${totalTime}</h4>
|
||||
${report.task_stats.map(taskStat => `
|
||||
<div class="report-task">
|
||||
<div class="report-task-title">${this.escapeHtml(taskStat.task.title)}</div>
|
||||
<div class="report-task-time">${this.formatTime(taskStat.total_duration)}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 加载任务筛选器
|
||||
loadTaskFilter() {
|
||||
const taskFilter = document.getElementById('historyTaskFilter');
|
||||
taskFilter.innerHTML = '<option value="">所有任务</option>';
|
||||
|
||||
this.tasks.forEach(task => {
|
||||
const option = document.createElement('option');
|
||||
option.value = task.id;
|
||||
option.textContent = task.title;
|
||||
taskFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 加载时间历史
|
||||
async loadTimeHistory() {
|
||||
try {
|
||||
this.showLoading();
|
||||
const days = parseInt(document.getElementById('historyDays').value);
|
||||
const taskId = document.getElementById('historyTaskFilter').value || null;
|
||||
|
||||
const history = await api.getAllTimeHistory(days, taskId);
|
||||
this.renderTimeHistory(history);
|
||||
} catch (error) {
|
||||
console.error('加载时间历史失败:', error);
|
||||
this.showNotification('加载时间历史失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载任务时间历史
|
||||
async loadTaskTimeHistory() {
|
||||
if (!this.currentViewingTaskId) return;
|
||||
|
||||
try {
|
||||
this.showLoading();
|
||||
const days = parseInt(document.getElementById('taskHistoryDays').value);
|
||||
|
||||
const history = await api.getTaskTimeHistory(this.currentViewingTaskId, days);
|
||||
this.renderTaskTimeHistory(history);
|
||||
} catch (error) {
|
||||
console.error('加载任务时间历史失败:', error);
|
||||
this.showNotification('加载任务时间历史失败', 'error');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染时间历史
|
||||
renderTimeHistory(history) {
|
||||
const content = document.getElementById('timeHistoryContent');
|
||||
|
||||
if (history.daily_tasks.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="history-empty">
|
||||
<i class="fas fa-history"></i>
|
||||
<h3>暂无时间记录</h3>
|
||||
<p>在指定时间段内没有找到任何工作时间记录</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = history.daily_tasks.map(day => `
|
||||
<div class="history-day">
|
||||
<h4>
|
||||
${this.formatDate(day.date)}
|
||||
<span class="day-total-time">${this.formatTime(day.total_time)}</span>
|
||||
</h4>
|
||||
${day.tasks.map(task => `
|
||||
<div class="history-task">
|
||||
<div class="history-task-header">
|
||||
<div class="history-task-title">${this.escapeHtml(task.task.title)}</div>
|
||||
<div class="history-task-total">${this.formatTime(task.total_duration)}</div>
|
||||
</div>
|
||||
<div class="time-segments">
|
||||
${task.segments.map(segment => `
|
||||
<div class="time-segment">
|
||||
<div class="segment-timespan">
|
||||
${this.formatDateTime(segment.start_time)} - ${this.formatDateTime(segment.end_time)}
|
||||
</div>
|
||||
<div class="segment-time">
|
||||
<div class="segment-duration">${this.formatTime(segment.duration)}</div>
|
||||
<div class="segment-period">${this.formatTimePeriod(segment.start_time, segment.end_time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 渲染任务时间历史
|
||||
renderTaskTimeHistory(history) {
|
||||
const content = document.getElementById('taskTimeHistoryContent');
|
||||
|
||||
if (history.daily_segments.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="history-empty">
|
||||
<i class="fas fa-clock"></i>
|
||||
<h3>暂无时间记录</h3>
|
||||
<p>该任务在指定时间段内没有工作时间记录</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = history.daily_segments.map(day => `
|
||||
<div class="history-day">
|
||||
<h4>
|
||||
${this.formatDate(day.date)}
|
||||
<span class="day-total-time">${this.formatTime(day.total_duration)}</span>
|
||||
</h4>
|
||||
<div class="time-segments">
|
||||
${day.segments.map(segment => `
|
||||
<div class="time-segment">
|
||||
<div class="segment-timespan">
|
||||
${this.formatDateTime(segment.start_time)} - ${this.formatDateTime(segment.end_time)}
|
||||
</div>
|
||||
<div class="segment-time">
|
||||
<div class="segment-duration">${this.formatTime(segment.duration)}</div>
|
||||
<div class="segment-period">${this.formatTimePeriod(segment.start_time, segment.end_time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 工具方法
|
||||
getStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '待开始',
|
||||
'in_progress': '进行中',
|
||||
'completed': '已完成'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 后端时间为UTC(无Z),需要按UTC解析并按上海时区显示
|
||||
parseUtcISO(dateTimeString) {
|
||||
if (!dateTimeString) return null;
|
||||
// 如果字符串不包含时区信息,则按UTC处理(补上Z)
|
||||
const hasTimezone = /[zZ]|[\+\-]\d{2}:?\d{2}$/.test(dateTimeString);
|
||||
const normalized = hasTimezone ? dateTimeString : `${dateTimeString}Z`;
|
||||
return new Date(normalized);
|
||||
}
|
||||
|
||||
formatDate(dateString) {
|
||||
const date = this.parseUtcISO(dateString);
|
||||
if (!date) return '';
|
||||
return date.toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
}
|
||||
|
||||
formatDateTime(dateTimeString) {
|
||||
const date = this.parseUtcISO(dateTimeString);
|
||||
if (!date) return '';
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Shanghai'
|
||||
});
|
||||
}
|
||||
|
||||
formatTimePeriod(startTime, endTime) {
|
||||
const start = this.parseUtcISO(startTime);
|
||||
const end = this.parseUtcISO(endTime);
|
||||
const duration = Math.floor((end - start) / 1000);
|
||||
|
||||
if (duration < 60) {
|
||||
return `${duration}秒`;
|
||||
} else if (duration < 3600) {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}分${seconds}秒`;
|
||||
} else {
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
return `${hours}小时${minutes}分钟`;
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// 简单的通知实现
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
z-index: 3000;
|
||||
animation: slideIn 0.3s ease;
|
||||
max-width: 300px;
|
||||
`;
|
||||
|
||||
// 根据类型设置背景色
|
||||
const colors = {
|
||||
'success': '#48bb78',
|
||||
'error': '#f56565',
|
||||
'warning': '#ed8936',
|
||||
'info': '#4299e1'
|
||||
};
|
||||
notification.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 3秒后自动移除
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化应用
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.app = new WorkListApp();
|
||||
});
|
||||
268
frontend/js/timer.js
Normal file
268
frontend/js/timer.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// 计时器模块
|
||||
class TaskTimer {
|
||||
constructor() {
|
||||
this.activeTimers = new Map(); // 存储活跃的计时器
|
||||
this.timerIntervals = new Map(); // 存储定时器ID
|
||||
}
|
||||
|
||||
// 开始计时
|
||||
async startTimer(taskId) {
|
||||
try {
|
||||
// 检查是否已有活跃计时器
|
||||
if (this.activeTimers.has(taskId)) {
|
||||
throw new Error('该任务已在计时中');
|
||||
}
|
||||
|
||||
// 调用API开始计时
|
||||
const timeRecord = await api.startTimer(taskId);
|
||||
|
||||
// 创建本地计时器
|
||||
const timer = {
|
||||
taskId: taskId,
|
||||
startTime: new Date(),
|
||||
currentSessionDuration: 0, // 当前会话时长(从0开始)
|
||||
isRunning: true
|
||||
};
|
||||
|
||||
this.activeTimers.set(taskId, timer);
|
||||
|
||||
// 启动定时器更新
|
||||
const intervalId = setInterval(() => {
|
||||
this.updateTimer(taskId);
|
||||
}, 1000);
|
||||
|
||||
this.timerIntervals.set(taskId, intervalId);
|
||||
|
||||
// 更新UI
|
||||
this.updateTimerDisplay(taskId);
|
||||
this.updateTaskStatus(taskId, 'in_progress');
|
||||
|
||||
return timer;
|
||||
|
||||
} catch (error) {
|
||||
console.error('开始计时失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 停止计时
|
||||
async stopTimer(taskId) {
|
||||
try {
|
||||
const timer = this.activeTimers.get(taskId);
|
||||
if (!timer) {
|
||||
throw new Error('没有找到活跃的计时器');
|
||||
}
|
||||
|
||||
// 调用API停止计时
|
||||
await api.stopTimer(taskId);
|
||||
|
||||
// 清除本地计时器
|
||||
this.clearTimer(taskId);
|
||||
|
||||
// 更新UI
|
||||
this.updateTaskStatus(taskId, 'pending');
|
||||
this.updateTimerDisplay(taskId);
|
||||
|
||||
// 刷新任务列表以显示总时长
|
||||
if (window.app) {
|
||||
window.app.loadTasks();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('停止计时失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 清除计时器
|
||||
clearTimer(taskId) {
|
||||
const intervalId = this.timerIntervals.get(taskId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
this.timerIntervals.delete(taskId);
|
||||
}
|
||||
this.activeTimers.delete(taskId);
|
||||
}
|
||||
|
||||
// 更新计时器
|
||||
updateTimer(taskId) {
|
||||
const timer = this.activeTimers.get(taskId);
|
||||
if (!timer) return;
|
||||
|
||||
const now = new Date();
|
||||
// 计算当前会话的时长(从开始计时到现在)
|
||||
timer.currentSessionDuration = Math.floor((now - timer.startTime) / 1000);
|
||||
|
||||
this.updateTimerDisplay(taskId);
|
||||
}
|
||||
|
||||
// 更新计时器显示
|
||||
updateTimerDisplay(taskId) {
|
||||
const timer = this.activeTimers.get(taskId);
|
||||
const displayElement = document.querySelector(`[data-task-id="${taskId}"] .timer-display`);
|
||||
|
||||
if (!displayElement) return;
|
||||
|
||||
if (timer && timer.isRunning) {
|
||||
// 显示当前会话的时长(从0开始)
|
||||
const duration = timer.currentSessionDuration || 0;
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const seconds = duration % 60;
|
||||
|
||||
displayElement.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
displayElement.style.color = '#48bb78';
|
||||
} else {
|
||||
// 显示总时长
|
||||
this.updateTotalTimeDisplay(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新总时长显示
|
||||
async updateTotalTimeDisplay(taskId) {
|
||||
try {
|
||||
const tasks = await api.getTasks();
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
|
||||
if (task) {
|
||||
const displayElement = document.querySelector(`[data-task-id="${taskId}"] .timer-display`);
|
||||
if (displayElement) {
|
||||
const totalSeconds = task.total_time || 0;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
displayElement.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
displayElement.style.color = '#4a5568';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新总时长显示失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新任务状态
|
||||
updateTaskStatus(taskId, status) {
|
||||
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
||||
if (!taskElement) return;
|
||||
|
||||
// 更新状态类
|
||||
taskElement.className = taskElement.className.replace(/in-progress|completed|pending/g, '');
|
||||
taskElement.classList.add(status);
|
||||
|
||||
// 更新状态显示
|
||||
const statusElement = taskElement.querySelector('.task-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = this.getStatusText(status);
|
||||
statusElement.className = `task-status ${status}`;
|
||||
}
|
||||
|
||||
// 更新按钮状态
|
||||
this.updateTimerButtons(taskId, status);
|
||||
}
|
||||
|
||||
// 更新计时器按钮
|
||||
updateTimerButtons(taskId, status) {
|
||||
const startBtn = document.querySelector(`[data-task-id="${taskId}"] .start-timer-btn`);
|
||||
const stopBtn = document.querySelector(`[data-task-id="${taskId}"] .stop-timer-btn`);
|
||||
|
||||
if (startBtn && stopBtn) {
|
||||
if (status === 'in_progress') {
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
startBtn.style.display = 'inline-flex';
|
||||
stopBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
getStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '待开始',
|
||||
'in_progress': '进行中',
|
||||
'completed': '已完成'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
formatTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 检查是否有活跃计时器
|
||||
hasActiveTimer(taskId) {
|
||||
return this.activeTimers.has(taskId);
|
||||
}
|
||||
|
||||
// 获取所有活跃计时器
|
||||
getActiveTimers() {
|
||||
return Array.from(this.activeTimers.values());
|
||||
}
|
||||
|
||||
// 停止所有计时器
|
||||
async stopAllTimers() {
|
||||
const promises = Array.from(this.activeTimers.keys()).map(taskId =>
|
||||
this.stopTimer(taskId).catch(error => {
|
||||
console.error(`停止任务${taskId}计时失败:`, error);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// 恢复未结束的计时器(用于页面重新加载后)
|
||||
async restoreTimers(tasks) {
|
||||
try {
|
||||
const taskIds = tasks.map(task => task.id);
|
||||
if (!taskIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await api.getTimerStatuses(taskIds);
|
||||
|
||||
for (const task of tasks) {
|
||||
const timerStatus = statuses?.[task.id];
|
||||
|
||||
if (timerStatus && timerStatus.is_running) {
|
||||
// 使用服务器返回的开始时间(从ISO格式字符串创建Date对象)
|
||||
const startTime = new Date(timerStatus.start_time);
|
||||
const now = new Date();
|
||||
|
||||
// 恢复计时器
|
||||
const timer = {
|
||||
taskId: task.id,
|
||||
startTime: startTime,
|
||||
currentSessionDuration: Math.floor((now - startTime) / 1000),
|
||||
isRunning: true
|
||||
};
|
||||
|
||||
this.activeTimers.set(task.id, timer);
|
||||
|
||||
// 启动定时器更新
|
||||
const intervalId = setInterval(() => {
|
||||
this.updateTimer(task.id);
|
||||
}, 1000);
|
||||
|
||||
this.timerIntervals.set(task.id, intervalId);
|
||||
|
||||
// 更新UI
|
||||
this.updateTimerDisplay(task.id);
|
||||
this.updateTaskStatus(task.id, 'in_progress');
|
||||
|
||||
console.log(`恢复了任务 ${task.id} 的计时器`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复计时器失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 创建全局计时器实例
|
||||
const taskTimer = new TaskTimer();
|
||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from worklist!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
pyproject.toml
Normal file
7
pyproject.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[project]
|
||||
name = "worklist"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
95
start.py
Normal file
95
start.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
工作任务管理系统启动脚本
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import webbrowser
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def check_python_version():
|
||||
"""检查Python版本"""
|
||||
if sys.version_info < (3, 7):
|
||||
print("错误: 需要Python 3.7或更高版本")
|
||||
sys.exit(1)
|
||||
|
||||
def install_dependencies():
|
||||
"""安装依赖"""
|
||||
print("正在安装Python依赖...")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "backend/requirements.txt"])
|
||||
print("✓ Python依赖安装完成")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"错误: 安装依赖失败 - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def create_env_file():
|
||||
"""创建环境变量文件"""
|
||||
env_file = Path("backend/.env")
|
||||
if not env_file.exists():
|
||||
print("创建环境变量文件...")
|
||||
with open(env_file, "w", encoding="utf-8") as f:
|
||||
f.write("""# OpenAI API配置(可选,用于AI润色功能)
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///worklist.db
|
||||
|
||||
# Flask配置
|
||||
SECRET_KEY=your-secret-key-here
|
||||
""")
|
||||
print("✓ 环境变量文件已创建: backend/.env")
|
||||
print("提示: 如需使用AI润色功能,请在.env文件中设置OPENAI_API_KEY")
|
||||
|
||||
def start_server():
|
||||
"""启动服务器"""
|
||||
print("正在启动服务器...")
|
||||
os.chdir("backend")
|
||||
try:
|
||||
# 启动Flask应用
|
||||
subprocess.run([sys.executable, "app.py"])
|
||||
except KeyboardInterrupt:
|
||||
print("\n服务器已停止")
|
||||
except Exception as e:
|
||||
print(f"错误: 启动服务器失败 - {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("=" * 50)
|
||||
print("工作任务管理系统")
|
||||
print("=" * 50)
|
||||
|
||||
# 检查Python版本
|
||||
check_python_version()
|
||||
|
||||
# 安装依赖
|
||||
install_dependencies()
|
||||
|
||||
# 创建环境变量文件
|
||||
create_env_file()
|
||||
|
||||
print("\n启动说明:")
|
||||
print("1. 服务器启动后,请在浏览器中访问: http://localhost:5000")
|
||||
print("2. 按 Ctrl+C 停止服务器")
|
||||
print("3. 如需使用AI润色功能,请配置OpenAI API密钥")
|
||||
print("\n正在启动服务器...")
|
||||
|
||||
# 延迟2秒后自动打开浏览器
|
||||
def open_browser():
|
||||
time.sleep(2)
|
||||
webbrowser.open("http://localhost:5000")
|
||||
|
||||
import threading
|
||||
browser_thread = threading.Thread(target=open_browser)
|
||||
browser_thread.daemon = True
|
||||
browser_thread.start()
|
||||
|
||||
# 启动服务器
|
||||
start_server()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user