feat: 添加 Goal, GoalRelation, StudentGoal 三个数据模型

- Goal: 目标表,支持存储学习目标
- GoalRelation: 目标自关联多对多表,支持 DAG 结构
- StudentGoal: 学员目标记录表,关联学员和目标
This commit is contained in:
hmo
2026-04-23 20:10:08 +08:00
parent 285979ff70
commit b54b6c7aec
21 changed files with 3229 additions and 0 deletions
@@ -0,0 +1,553 @@
# 目标管理模块实现计划
> **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 → ★-★★★★★)
@@ -0,0 +1,143 @@
# 目标管理模块设计
> 日期:2026-04-23
> 状态:待评审
## 概述
目标管理模块用于管理系统化的钢琴学习目标,支持目标间的多对多关联(DAG结构),以及目标与学员的关联记录。
## 数据模型
### 1. Goal (目标)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Integer | 主键,自增 |
| name | String(100) | 目标名称,必填 |
| content | Text | Markdown 格式详细内容 |
| created_at | DateTime | 创建时间 |
| updated_at | DateTime | 更新时间 |
### 2. GoalRelation (目标关联 - 自关联多对多)
| 字段 | 类型 | 说明 |
|------|------|------|
| parent_goal_id | Integer | 外键 → goals.id |
| child_goal_id | Integer | 外键 → goals.id |
| PRIMARY KEY | (parent_goal_id, child_goal_id) | 联合主键 |
**约束**
- 禁止循环引用(A→B→C→A
- 自关联:goal 可以是自身的父/子节点
### 3. StudentGoal (学员目标记录)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Integer | 主键,自增 |
| student_id | Integer | 外键 → students.id |
| goal_id | Integer | 外键 → goals.id |
| status | String(20) | 状态:未开始/进行中/已完成 |
| mastery_level | Integer | 完成度:1-51最少,5最精通) |
| deadline | DateTime | 截止日期 |
| completed_at | DateTime | 完成日期 |
| created_at | DateTime | 创建时间 |
**显示规则**
- `mastery_level` 直接渲染为对应数量的五角星(★)
---
## API 接口
### 目标管理
```
GET /api/goals # 获取所有目标
POST /api/goals # 创建目标
GET /api/goals/<id> # 获取目标详情
PUT /api/goals/<id> # 更新目标
DELETE /api/goals/<id> # 删除目标
```
### 目标关联
```
GET /api/goals/<id>/parents # 获取父目标列表
GET /api/goals/<id>/children # 获取子目标列表
POST /api/goals/<id>/children # 添加子目标关联
DELETE /api/goals/<id>/children/<child_id> # 移除关联
```
### 学员目标
```
GET /api/students/<student_id>/goals # 获取学员的目标列表
POST /api/students/<student_id>/goals # 为学员添加目标
PUT /api/students/<student_id>/goals/<goal_id> # 更新学员目标状态
DELETE /api/students/<student_id>/goals/<goal_id> # 移除学员的目标
```
---
## 循环引用检测
在添加关联时必须检测:
```python
def has_cycle(goal_id, new_child_id):
"""检测添加 new_child_id 作为 goal_id 的子目标是否会形成循环"""
visited = set()
stack = [new_child_id]
while stack:
current = stack.pop()
if current == goal_id:
return True # 发现循环
if current in visited:
continue
visited.add(current)
# 获取 current 的所有子目标,继续检测
for child in get_children(current):
stack.append(child)
return False
```
---
## 前端页面
### 1. 目标管理页面 (`/goals`)
- 目标列表(可树状展示)
- 创建/编辑/删除目标
- 目标内容 Markdown 编辑器
- 关联管理(拖拽或选择器添加关联)
### 2. 学员详情页目标区块
在现有 `student.html` 中扩展:
- 显示学员的目标列表
- 每项目标显示:名称、状态、★完成度、截止日期
- 可添加/移除/编辑目标
- 状态变更触发刷新
---
## 实现顺序
1. **数据模型** - goals, goal_relations, student_goals 表
2. **目标 CRUD API** - 基础的增删改查
3. **目标关联 API** - 关联管理 + 循环检测
4. **学员目标 API** - 学员与目标的关联管理
5. **目标管理页面** - 目标列表 + 关联管理
6. **学员详情页扩展** - 目标区块
---
## 依赖关系
- 无外部依赖
- 复用现有的 Markdown 编辑器(EasyMDE)和星级组件