Files
piano-plan/docs/superpowers/plans/2026-04-23-goal-management-plan.md
T
hmo b54b6c7aec feat: 添加 Goal, GoalRelation, StudentGoal 三个数据模型
- Goal: 目标表,支持存储学习目标
- GoalRelation: 目标自关联多对多表,支持 DAG 结构
- StudentGoal: 学员目标记录表,关联学员和目标
2026-04-23 20:10:08 +08:00

554 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 目标管理模块实现计划
> **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 → ★-★★★★★)