b54b6c7aec
- Goal: 目标表,支持存储学习目标 - GoalRelation: 目标自关联多对多表,支持 DAG 结构 - StudentGoal: 学员目标记录表,关联学员和目标
554 lines
16 KiB
Markdown
554 lines
16 KiB
Markdown
# 目标管理模块实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan.
|
||
|
||
**Goal:** 实现目标管理模块,支持目标的CRUD、目标间的DAG关联、与学员的关联记录
|
||
|
||
**Architecture:** 使用Flask-SQLAlchemy定义数据模型,通过RESTful API暴露接口,前端复用现有EasyMDE和星级组件
|
||
|
||
**Tech Stack:** Flask, SQLite, SQLAlchemy, EasyMDE
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
```
|
||
app/
|
||
├── models.py # Goal, GoalRelation, StudentGoal 模型
|
||
├── routes/
|
||
│ ├── goals.py # 目标 CRUD + 关联管理 API
|
||
│ └── student_goals.py # 学员目标 API
|
||
└── templates/
|
||
├── goals.html # 目标管理页面
|
||
└── student.html # 扩展学员详情页的目标区块
|
||
|
||
docs/
|
||
└── superpowers/plans/
|
||
└── 2026-04-23-goal-management.md # 设计文档
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: 数据模型
|
||
|
||
**Files:**
|
||
- Modify: `app/models.py:193-240` (在 PracticePlan 之前添加新模型)
|
||
|
||
- [ ] **Step 1: 在 models.py 添加 Goal 模型**
|
||
|
||
```python
|
||
class Goal(db.Model):
|
||
"""目标表"""
|
||
__tablename__ = "goals"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String(100), nullable=False)
|
||
content = db.Column(db.Text)
|
||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"name": self.name,
|
||
"content": self.content,
|
||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 models.py 添加 GoalRelation 模型**
|
||
|
||
```python
|
||
class GoalRelation(db.Model):
|
||
"""目标关联表 - 自关联多对多"""
|
||
__tablename__ = "goal_relations"
|
||
|
||
parent_goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), primary_key=True)
|
||
child_goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), primary_key=True)
|
||
|
||
parent = db.relationship("Goal", foreign_keys=[parent_goal_id], backref="child_relations")
|
||
child = db.relationship("Goal", foreign_keys=[child_goal_id], backref="parent_relations")
|
||
```
|
||
|
||
- [ ] **Step 3: 在 models.py 添加 StudentGoal 模型**
|
||
|
||
```python
|
||
class StudentGoal(db.Model):
|
||
"""学员目标记录表"""
|
||
__tablename__ = "student_goals"
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
student_id = db.Column(db.Integer, db.ForeignKey("students.id"), nullable=False)
|
||
goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), nullable=False)
|
||
status = db.Column(db.String(20), default="未开始") # 未开始/进行中/已完成
|
||
mastery_level = db.Column(db.Integer, default=1) # 1-5
|
||
deadline = db.Column(db.DateTime)
|
||
completed_at = db.Column(db.DateTime)
|
||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||
|
||
student = db.relationship("Student", backref="goal_records")
|
||
goal = db.relationship("Goal")
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"id": self.id,
|
||
"student_id": self.student_id,
|
||
"goal_id": self.goal_id,
|
||
"goal_name": self.goal.name if self.goal else None,
|
||
"status": self.status,
|
||
"mastery_level": self.mastery_level,
|
||
"deadline": self.deadline.isoformat() if self.deadline else None,
|
||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行 lint 验证**
|
||
|
||
Run: `lsp_diagnostics('app/models.py')`
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add app/models.py
|
||
git commit -m "feat: 添加 Goal, GoalRelation, StudentGoal 模型"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 目标 CRUD API
|
||
|
||
**Files:**
|
||
- Create: `app/routes/goals.py`
|
||
|
||
- [ ] **Step 1: 创建 routes/goals.py**
|
||
|
||
```python
|
||
from flask import Blueprint, request, jsonify
|
||
from app.models import db, Goal
|
||
from app.routes.auth import login_required_json
|
||
|
||
goals_bp = Blueprint("goals", __name__)
|
||
|
||
@goals_bp.route("/api/goals", methods=["GET"])
|
||
@login_required_json
|
||
def get_goals():
|
||
goals = Goal.query.order_by(Goal.created_at.desc()).all()
|
||
return jsonify([g.to_dict() for g in goals])
|
||
|
||
@goals_bp.route("/api/goals", methods=["POST"])
|
||
@login_required_json
|
||
def create_goal():
|
||
data = request.get_json()
|
||
goal = Goal(name=data["name"], content=data.get("content", ""))
|
||
db.session.add(goal)
|
||
db.session.commit()
|
||
return jsonify(goal.to_dict()), 201
|
||
|
||
@goals_bp.route("/api/goals/<int:goal_id>", methods=["GET"])
|
||
@login_required_json
|
||
def get_goal(goal_id):
|
||
goal = Goal.query.get_or_404(goal_id)
|
||
return jsonify(goal.to_dict())
|
||
|
||
@goals_bp.route("/api/goals/<int:goal_id>", methods=["PUT"])
|
||
@login_required_json
|
||
def update_goal(goal_id):
|
||
goal = Goal.query.get_or_404(goal_id)
|
||
data = request.get_json()
|
||
if "name" in data:
|
||
goal.name = data["name"]
|
||
if "content" in data:
|
||
goal.content = data["content"]
|
||
db.session.commit()
|
||
return jsonify(goal.to_dict())
|
||
|
||
@goals_bp.route("/api/goals/<int:goal_id>", methods=["DELETE"])
|
||
@login_required_json
|
||
def delete_goal(goal_id):
|
||
goal = Goal.query.get_or_404(goal_id)
|
||
db.session.delete(goal)
|
||
db.session.commit()
|
||
return jsonify({"message": "删除成功"})
|
||
```
|
||
|
||
- [ ] **Step 2: 在 app/routes/__init__.py 注册蓝图**
|
||
|
||
```python
|
||
from app.routes import goals
|
||
app.register_blueprint(goals.goals_bp)
|
||
```
|
||
|
||
- [ ] **Step 3: 运行 lint 验证**
|
||
|
||
Run: `lsp_diagnostics('app/routes/goals.py')`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add app/routes/goals.py app/routes/__init__.py
|
||
git commit -m "feat: 目标 CRUD API"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 目标关联 API + 循环检测
|
||
|
||
**Files:**
|
||
- Modify: `app/routes/goals.py` (添加关联接口)
|
||
|
||
- [ ] **Step 1: 添加循环检测函数**
|
||
|
||
```python
|
||
def _has_cycle(parent_id, child_id):
|
||
"""检测添加 child_id 作为 parent_id 的子目标是否会形成循环"""
|
||
visited = set()
|
||
stack = [child_id]
|
||
|
||
while stack:
|
||
current = stack.pop()
|
||
if current == parent_id:
|
||
return True
|
||
if current in visited:
|
||
continue
|
||
visited.add(current)
|
||
for rel in GoalRelation.query.filter_by(parent_goal_id=current).all():
|
||
stack.append(rel.child_goal_id)
|
||
|
||
return False
|
||
```
|
||
|
||
- [ ] **Step 2: 添加关联接口**
|
||
|
||
```python
|
||
@goals_bp.route("/api/goals/<int:goal_id>/children", methods=["GET"])
|
||
@login_required_json
|
||
def get_goal_children(goal_id):
|
||
relations = GoalRelation.query.filter_by(parent_goal_id=goal_id).all()
|
||
child_ids = [r.child_goal_id for r in relations]
|
||
children = Goal.query.filter(Goal.id.in_(child_ids)).all() if child_ids else []
|
||
return jsonify([c.to_dict() for c in children])
|
||
|
||
@goals_bp.route("/api/goals/<int:goal_id>/parents", methods=["GET"])
|
||
@login_required_json
|
||
def get_goal_parents(goal_id):
|
||
relations = GoalRelation.query.filter_by(child_goal_id=goal_id).all()
|
||
parent_ids = [r.parent_goal_id for r in relations]
|
||
parents = Goal.query.filter(Goal.id.in_(parent_ids)).all() if parent_ids else []
|
||
return jsonify([p.to_dict() for p in parents])
|
||
|
||
@goals_bp.route("/api/goals/<int:goal_id>/children", methods=["POST"])
|
||
@login_required_json
|
||
def add_goal_child(goal_id):
|
||
data = request.get_json()
|
||
child_id = data["child_goal_id"]
|
||
|
||
if goal_id == child_id:
|
||
return jsonify({"error": "不能将目标关联到自身"}), 400
|
||
|
||
if _has_cycle(goal_id, child_id):
|
||
return jsonify({"error": "添加此关联会形成循环引用"}), 400
|
||
|
||
# 检查是否已存在
|
||
existing = GoalRelation.query.filter_by(parent_goal_id=goal_id, child_goal_id=child_id).first()
|
||
if existing:
|
||
return jsonify({"error": "关联已存在"}), 400
|
||
|
||
relation = GoalRelation(parent_goal_id=goal_id, child_goal_id=child_id)
|
||
db.session.add(relation)
|
||
db.session.commit()
|
||
return jsonify({"message": "添加成功"})
|
||
|
||
@goals_bp.route("/api/goals/<int:goal_id>/children/<int:child_id>", methods=["DELETE"])
|
||
@login_required_json
|
||
def remove_goal_child(goal_id, child_id):
|
||
relation = GoalRelation.query.filter_by(parent_goal_id=goal_id, child_goal_id=child_id).first()
|
||
if relation:
|
||
db.session.delete(relation)
|
||
db.session.commit()
|
||
return jsonify({"message": "移除成功"})
|
||
```
|
||
|
||
- [ ] **Step 3: 运行 lint 验证**
|
||
|
||
Run: `lsp_diagnostics('app/routes/goals.py')`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add app/routes/goals.py
|
||
git commit -m "feat: 目标关联 API + 循环检测"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 学员目标 API
|
||
|
||
**Files:**
|
||
- Create: `app/routes/student_goals.py`
|
||
|
||
- [ ] **Step 1: 创建 student_goals.py**
|
||
|
||
```python
|
||
from flask import Blueprint, request, jsonify
|
||
from app.models import db, Student, Goal, StudentGoal
|
||
from app.routes.auth import login_required_json
|
||
from datetime import datetime
|
||
|
||
student_goals_bp = Blueprint("student_goals", __name__)
|
||
|
||
@student_goals_bp.route("/api/students/<int:student_id>/goals", methods=["GET"])
|
||
@login_required_json
|
||
def get_student_goals(student_id):
|
||
Student.query.get_or_404(student_id)
|
||
records = StudentGoal.query.filter_by(student_id=student_id).all()
|
||
return jsonify([r.to_dict() for r in records])
|
||
|
||
@student_goals_bp.route("/api/students/<int:student_id>/goals", methods=["POST"])
|
||
@login_required_json
|
||
def add_student_goal(student_id):
|
||
Student.query.get_or_404(student_id)
|
||
data = request.get_json()
|
||
goal_id = data["goal_id"]
|
||
|
||
# 检查目标是否存在
|
||
Goal.query.get_or_404(goal_id)
|
||
|
||
# 检查是否已添加
|
||
existing = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first()
|
||
if existing:
|
||
return jsonify({"error": "该目标已添加"}), 400
|
||
|
||
record = StudentGoal(
|
||
student_id=student_id,
|
||
goal_id=goal_id,
|
||
status="未开始",
|
||
mastery_level=1
|
||
)
|
||
db.session.add(record)
|
||
db.session.commit()
|
||
return jsonify(record.to_dict()), 201
|
||
|
||
@student_goals_bp.route("/api/students/<int:student_id>/goals/<int:goal_id>", methods=["PUT"])
|
||
@login_required_json
|
||
def update_student_goal(student_id, goal_id):
|
||
record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404()
|
||
data = request.get_json()
|
||
|
||
if "status" in data:
|
||
record.status = data["status"]
|
||
if data["status"] == "已完成":
|
||
record.completed_at = datetime.now()
|
||
if "mastery_level" in data:
|
||
record.mastery_level = data["mastery_level"]
|
||
if "deadline" in data:
|
||
record.deadline = datetime.fromisoformat(data["deadline"]) if data["deadline"] else None
|
||
|
||
db.session.commit()
|
||
return jsonify(record.to_dict())
|
||
|
||
@student_goals_bp.route("/api/students/<int:student_id>/goals/<int:goal_id>", methods=["DELETE"])
|
||
@login_required_json
|
||
def remove_student_goal(student_id, goal_id):
|
||
record = StudentGoal.query.filter_by(student_id=student_id, goal_id=goal_id).first_or_404()
|
||
db.session.delete(record)
|
||
db.session.commit()
|
||
return jsonify({"message": "移除成功"})
|
||
```
|
||
|
||
- [ ] **Step 2: 在 app/routes/__init__.py 注册蓝图**
|
||
|
||
```python
|
||
from app.routes import student_goals
|
||
app.register_blueprint(student_goals.student_goals_bp)
|
||
```
|
||
|
||
- [ ] **Step 3: 运行 lint 验证**
|
||
|
||
Run: `lsp_diagnostics('app/routes/student_goals.py')`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add app/routes/student_goals.py app/routes/__init__.py
|
||
git commit -m "feat: 学员目标 API"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: 目标管理页面
|
||
|
||
**Files:**
|
||
- Create: `app/templates/goals.html`
|
||
|
||
- [ ] **Step 1: 创建 goals.html**
|
||
|
||
参考现有模板结构,包含:
|
||
- 目标列表(树状或平铺)
|
||
- 创建/编辑目标 Modal(使用 EasyMDE)
|
||
- 关联管理(添加/移除子目标)
|
||
|
||
- [ ] **Step 2: 添加路由**
|
||
|
||
```python
|
||
@goals_bp.route("/goals")
|
||
@login_required_json
|
||
def goals_page():
|
||
return render_template("goals.html")
|
||
```
|
||
|
||
- [ ] **Step 3: 运行 lint 验证**
|
||
|
||
Run: `lsp_diagnostics('app/templates/goals.html')`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add app/templates/goals.html app/routes/goals.py
|
||
git commit -m "feat: 目标管理页面"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 学员详情页目标区块
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/student.html`
|
||
|
||
- [ ] **Step 1: 在 student.html 添加目标区块**
|
||
|
||
在现有问题记录和练习方案之间添加目标区块:
|
||
- 显示学员目标列表(名称、状态、★完成度、截止日期)
|
||
- 添加/移除目标
|
||
- 编辑目标状态
|
||
|
||
- [ ] **Step 2: 添加 JS 函数**
|
||
|
||
- `loadGoals()` - 加载目标列表
|
||
- `renderGoalList()` - 渲染目标
|
||
- `showAddGoalModal()` - 显示添加目标弹窗
|
||
- `saveAddGoal()` - 保存添加
|
||
- `updateGoalStatus()` - 更新状态
|
||
|
||
- [ ] **Step 3: 运行 lint 验证**
|
||
|
||
Run: `lsp_diagnostics('app/templates/student.html')`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add app/templates/student.html
|
||
git commit -m "feat: 学员详情页目标区块"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 数据库迁移
|
||
|
||
**Files:**
|
||
- Modify: `app/__init__.py` (在 db.create_all() 之前添加迁移逻辑)
|
||
|
||
- [ ] **Step 1: 添加表存在性检测和创建**
|
||
|
||
在 `create_app()` 中 db.create_all() 之后添加:
|
||
|
||
```python
|
||
# 检查 goals 表是否存在,不存在则创建
|
||
result = db.session.execute(
|
||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='goals'")
|
||
)
|
||
if not result.fetchone():
|
||
db.session.execute(text("""
|
||
CREATE TABLE goals (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name VARCHAR(100) NOT NULL,
|
||
content TEXT,
|
||
created_at DATETIME,
|
||
updated_at DATETIME
|
||
)
|
||
"""))
|
||
db.session.commit()
|
||
|
||
# 检查 goal_relations 表
|
||
result = db.session.execute(
|
||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='goal_relations'")
|
||
)
|
||
if not result.fetchone():
|
||
db.session.execute(text("""
|
||
CREATE TABLE goal_relations (
|
||
parent_goal_id INTEGER NOT NULL,
|
||
child_goal_id INTEGER NOT NULL,
|
||
PRIMARY KEY (parent_goal_id, child_goal_id),
|
||
FOREIGN KEY (parent_goal_id) REFERENCES goals(id),
|
||
FOREIGN KEY (child_goal_id) REFERENCES goals(id)
|
||
)
|
||
"""))
|
||
db.session.commit()
|
||
|
||
# 检查 student_goals 表
|
||
result = db.session.execute(
|
||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='student_goals'")
|
||
)
|
||
if not result.fetchone():
|
||
db.session.execute(text("""
|
||
CREATE TABLE student_goals (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
student_id INTEGER NOT NULL,
|
||
goal_id INTEGER NOT NULL,
|
||
status VARCHAR(20) DEFAULT '未开始',
|
||
mastery_level INTEGER DEFAULT 1,
|
||
deadline DATETIME,
|
||
completed_at DATETIME,
|
||
created_at DATETIME,
|
||
FOREIGN KEY (student_id) REFERENCES students(id),
|
||
FOREIGN KEY (goal_id) REFERENCES goals(id)
|
||
)
|
||
"""))
|
||
db.session.commit()
|
||
```
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add app/__init__.py
|
||
git commit -m "feat: 数据库迁移脚本 - goals, goal_relations, student_goals 表"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 更新文档
|
||
|
||
**Files:**
|
||
- Modify: `docs/MODELS.md` (添加新表说明)
|
||
- Modify: `docs/API.md` (添加新 API 说明)
|
||
- Modify: `docs/STRUCTURE.md` (添加新文件)
|
||
|
||
- [ ] **Step 1: 更新 MODELS.md**
|
||
|
||
添加 Goal, GoalRelation, StudentGoal 表说明
|
||
|
||
- [ ] **Step 2: 更新 API.md**
|
||
|
||
添加目标 API 和学员目标 API 说明
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add docs/MODELS.md docs/API.md docs/STRUCTURE.md
|
||
git commit -m "docs: 更新文档 - 目标管理模块"
|
||
```
|
||
|
||
---
|
||
|
||
## 验证清单
|
||
|
||
- [ ]goals 表创建成功
|
||
- [ ]goal_relations 表创建成功
|
||
- [ ]student_goals 表创建成功
|
||
- [ ] 目标 CRUD API 测试通过
|
||
- [ ] 目标关联 API 测试通过(包含循环检测)
|
||
- [ ] 学员目标 API 测试通过
|
||
- [ ] 目标管理页面可访问
|
||
- [ ] 学员详情页目标区块正常显示
|
||
- [ ] 星级显示正确(1-5 → ★-★★★★★)
|