1612 lines
42 KiB
Markdown
1612 lines
42 KiB
Markdown
|
|
# 交互广场重新设计实现计划
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 将论坛重构为三个AI驱动的智能板块:AI学习、AI建议、AI交互
|
|||
|
|
|
|||
|
|
**Architecture:** 前端三板块布局,后端三个Service处理业务逻辑,数据库新增三张表存储学习记录、建议和交互主题
|
|||
|
|
|
|||
|
|
**Tech Stack:** Vue 3 + TypeScript + FastAPI + SQLAlchemy + LLM
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 文件结构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
backend/app/
|
|||
|
|
├── models/
|
|||
|
|
│ ├── learning_record.py # 新建 - 学习记录模型
|
|||
|
|
│ ├── suggestion.py # 新建 - 建议模型
|
|||
|
|
│ └── interactive_topic.py # 新建 - 交互主题模型
|
|||
|
|
├── schemas/
|
|||
|
|
│ ├── learning.py # 新建 - LearningRecord Pydantic schemas
|
|||
|
|
│ ├── suggestion.py # 新建 - Suggestion Pydantic schemas
|
|||
|
|
│ └── interactive.py # 新建 - InteractiveTopic Pydantic schemas
|
|||
|
|
├── services/
|
|||
|
|
│ ├── learning_service.py # 新建 - AI学习服务
|
|||
|
|
│ ├── suggestion_service.py # 新建 - AI建议服务
|
|||
|
|
│ └── interactive_service.py # 新建 - AI交互服务
|
|||
|
|
└── routers/
|
|||
|
|
└── forum.py # 修改 - 添加新接口
|
|||
|
|
|
|||
|
|
frontend/src/
|
|||
|
|
├── api/
|
|||
|
|
│ └── forum.ts # 修改 - 添加新API方法
|
|||
|
|
├── views/
|
|||
|
|
│ └── ForumView.vue # 修改 - 重写为三板块布局
|
|||
|
|
└── components/forum/
|
|||
|
|
├── LearningSection.vue # 新建 - AI学习板块
|
|||
|
|
│ ├── LearningSummaryCard.vue # 新建 - 今日摘要卡片
|
|||
|
|
│ ├── LearningTimeline.vue # 新建 - 学习历史时间线
|
|||
|
|
│ └── LearningStats.vue # 新建 - 图谱更新统计
|
|||
|
|
├── SuggestionSection.vue # 新建 - AI建议板块
|
|||
|
|
│ └── SuggestionCard.vue # 新建 - 建议卡片
|
|||
|
|
└── InteractiveSection.vue # 新建 - AI交互板块
|
|||
|
|
└── LearningInput.vue # 新建 - 学习主题输入框
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: 创建 LearningRecord 模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/models/learning_record.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 learning_record.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy import Column, String, Text, ForeignKey, JSON
|
|||
|
|
from app.models.base import BaseModel
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearningRecord(BaseModel):
|
|||
|
|
__tablename__ = "learning_records"
|
|||
|
|
|
|||
|
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
|||
|
|
learning_type = Column(String(50), nullable=False) # concept, technology, workflow
|
|||
|
|
topic = Column(String(500), nullable=False) # 学习主题
|
|||
|
|
summary = Column(Text, nullable=False) # AI生成的学习摘要
|
|||
|
|
source = Column(String(50), nullable=False) # conversation, kanban, knowledge
|
|||
|
|
source_ids = Column(JSON, nullable=True) # 来源ID列表
|
|||
|
|
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 models/__init__.py 中导出**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from app.models.learning_record import LearningRecord
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: 创建 Suggestion 模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/models/suggestion.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 suggestion.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy import Column, String, Text, ForeignKey, JSON, Boolean
|
|||
|
|
from app.models.base import BaseModel
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Suggestion(BaseModel):
|
|||
|
|
__tablename__ = "suggestions"
|
|||
|
|
|
|||
|
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
|||
|
|
suggestion_type = Column(String(50), nullable=False) # knowledge, efficiency, skill
|
|||
|
|
title = Column(String(500), nullable=False) # 建议标题
|
|||
|
|
content = Column(Text, nullable=False) # 建议内容
|
|||
|
|
source_data = Column(JSON, nullable=True) # 分析依据
|
|||
|
|
is_read = Column(Boolean, default=False) # 是否已读
|
|||
|
|
is_dismissed = Column(Boolean, default=False) # 是否忽略
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 models/__init__.py 中导出**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from app.models.suggestion import Suggestion
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: 创建 InteractiveTopic 模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/models/interactive_topic.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 interactive_topic.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy import Column, String, Text, ForeignKey, JSON, DateTime
|
|||
|
|
from sqlalchemy.orm import relationship
|
|||
|
|
from app.models.base import BaseModel
|
|||
|
|
|
|||
|
|
|
|||
|
|
class InteractiveTopic(BaseModel):
|
|||
|
|
__tablename__ = "interactive_topics"
|
|||
|
|
|
|||
|
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
|||
|
|
topic = Column(String(500), nullable=False) # 学习主题
|
|||
|
|
status = Column(String(50), nullable=False) # pending, learning, completed, failed
|
|||
|
|
result = Column(Text, nullable=True) # 学习结果/报告
|
|||
|
|
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
|
|||
|
|
source = Column(String(50), nullable=False) # user_initiated, ai_proactive
|
|||
|
|
completed_at = Column(DateTime, nullable=True)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 在 models/__init__.py 中导出**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from app.models.interactive_topic import InteractiveTopic
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: 创建 Pydantic Schemas
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/schemas/learning.py`
|
|||
|
|
- Create: `backend/app/schemas/suggestion.py`
|
|||
|
|
- Create: `backend/app/schemas/interactive.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 learning.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearningRecordOut(BaseModel):
|
|||
|
|
id: str
|
|||
|
|
learning_type: str
|
|||
|
|
topic: str
|
|||
|
|
summary: str
|
|||
|
|
source: str
|
|||
|
|
source_ids: Optional[dict] = None
|
|||
|
|
kg_nodes_created: Optional[list[str]] = None
|
|||
|
|
created_at: str
|
|||
|
|
|
|||
|
|
model_config = {"from_attributes": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearningSummaryOut(BaseModel):
|
|||
|
|
summary: str
|
|||
|
|
records: list[LearningRecordOut]
|
|||
|
|
stats: dict
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearningHistoryOut(BaseModel):
|
|||
|
|
records: list[LearningRecordOut]
|
|||
|
|
total: int
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 suggestion.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SuggestionOut(BaseModel):
|
|||
|
|
id: str
|
|||
|
|
suggestion_type: str
|
|||
|
|
title: str
|
|||
|
|
content: str
|
|||
|
|
source_data: Optional[dict] = None
|
|||
|
|
is_read: bool
|
|||
|
|
is_dismissed: bool
|
|||
|
|
created_at: str
|
|||
|
|
|
|||
|
|
model_config = {"from_attributes": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SuggestionListOut(BaseModel):
|
|||
|
|
suggestions: list[SuggestionOut]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 interactive.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
|
|||
|
|
class InteractiveTopicOut(BaseModel):
|
|||
|
|
id: str
|
|||
|
|
topic: str
|
|||
|
|
status: str
|
|||
|
|
result: Optional[str] = None
|
|||
|
|
kg_nodes_created: Optional[list[str]] = None
|
|||
|
|
source: str
|
|||
|
|
created_at: str
|
|||
|
|
completed_at: Optional[str] = None
|
|||
|
|
|
|||
|
|
model_config = {"from_attributes": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class InteractiveTopicsOut(BaseModel):
|
|||
|
|
user_initiated: list[InteractiveTopicOut]
|
|||
|
|
ai_proactive: list[InteractiveTopicOut]
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearnRequest(BaseModel):
|
|||
|
|
topic: str
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearnResponse(BaseModel):
|
|||
|
|
topic_id: str
|
|||
|
|
status: str
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: 创建 LearningService
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/services/learning_service.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 learning_service.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
from sqlalchemy import select, desc
|
|||
|
|
from app.models.learning_record import LearningRecord
|
|||
|
|
from app.models.conversation import Message
|
|||
|
|
from app.models.task import Task
|
|||
|
|
from app.models.knowledge_graph import KGNode
|
|||
|
|
from app.core.llm import get_llm_client
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LearningService:
|
|||
|
|
def __init__(self, db: AsyncSession):
|
|||
|
|
self.db = db
|
|||
|
|
self.llm = get_llm_client()
|
|||
|
|
|
|||
|
|
async def get_summary(self, user_id: str) -> dict:
|
|||
|
|
"""获取今日学习摘要"""
|
|||
|
|
today = datetime.utcnow().date()
|
|||
|
|
today_start = datetime.combine(today, datetime.min.time())
|
|||
|
|
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(LearningRecord)
|
|||
|
|
.where(
|
|||
|
|
LearningRecord.user_id == user_id,
|
|||
|
|
LearningRecord.created_at >= today_start
|
|||
|
|
)
|
|||
|
|
.order_by(desc(LearningRecord.created_at))
|
|||
|
|
)
|
|||
|
|
records = result.scalars().all()
|
|||
|
|
|
|||
|
|
if not records:
|
|||
|
|
return {
|
|||
|
|
"summary": "今日暂无学习记录",
|
|||
|
|
"records": [],
|
|||
|
|
"stats": {"nodes_created": 0, "edges_created": 0}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 生成摘要
|
|||
|
|
topics = [r.topic for r in records]
|
|||
|
|
summary = f"今日学习了 {len(topics)} 个主题:{', '.join(topics[:3])}"
|
|||
|
|
|
|||
|
|
# 统计
|
|||
|
|
nodes_count = sum(len(r.kg_nodes_created or []) for r in records)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"summary": summary,
|
|||
|
|
"records": [self._record_to_dict(r) for r in records],
|
|||
|
|
"stats": {"nodes_created": nodes_count, "edges_created": 0}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def get_history(self, user_id: str, page: int = 1, limit: int = 20) -> dict:
|
|||
|
|
"""获取学习历史"""
|
|||
|
|
offset = (page - 1) * limit
|
|||
|
|
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(LearningRecord)
|
|||
|
|
.where(LearningRecord.user_id == user_id)
|
|||
|
|
.order_by(desc(LearningRecord.created_at))
|
|||
|
|
.limit(limit)
|
|||
|
|
.offset(offset)
|
|||
|
|
)
|
|||
|
|
records = result.scalars().all()
|
|||
|
|
|
|||
|
|
count_result = await self.db.execute(
|
|||
|
|
select(LearningRecord)
|
|||
|
|
.where(LearningRecord.user_id == user_id)
|
|||
|
|
)
|
|||
|
|
total = len(count_result.scalars().all())
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"records": [self._record_to_dict(r) for r in records],
|
|||
|
|
"total": total
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _record_to_dict(self, record: LearningRecord) -> dict:
|
|||
|
|
return {
|
|||
|
|
"id": record.id,
|
|||
|
|
"learning_type": record.learning_type,
|
|||
|
|
"topic": record.topic,
|
|||
|
|
"summary": record.summary,
|
|||
|
|
"source": record.source,
|
|||
|
|
"source_ids": record.source_ids,
|
|||
|
|
"kg_nodes_created": record.kg_nodes_created,
|
|||
|
|
"created_at": record.created_at.isoformat() if record.created_at else None
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: 创建 SuggestionService
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/services/suggestion_service.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 suggestion_service.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
from app.models.suggestion import Suggestion
|
|||
|
|
from app.models.knowledge_graph import KGNode
|
|||
|
|
from app.core.llm import get_llm_client
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SuggestionService:
|
|||
|
|
def __init__(self, db: AsyncSession):
|
|||
|
|
self.db = db
|
|||
|
|
self.llm = get_llm_client()
|
|||
|
|
|
|||
|
|
async def get_suggestions(self, user_id: str) -> list[dict]:
|
|||
|
|
"""获取用户的所有建议"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(Suggestion)
|
|||
|
|
.where(
|
|||
|
|
Suggestion.user_id == user_id,
|
|||
|
|
Suggestion.is_dismissed == False
|
|||
|
|
)
|
|||
|
|
.order_by(Suggestion.created_at.desc())
|
|||
|
|
)
|
|||
|
|
suggestions = result.scalars().all()
|
|||
|
|
|
|||
|
|
return [self._suggestion_to_dict(s) for s in suggestions]
|
|||
|
|
|
|||
|
|
async def get_suggestion(self, suggestion_id: str, user_id: str) -> dict:
|
|||
|
|
"""获取单个建议"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(Suggestion).where(
|
|||
|
|
Suggestion.id == suggestion_id,
|
|||
|
|
Suggestion.user_id == user_id
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
suggestion = result.scalar_one_or_none()
|
|||
|
|
if not suggestion:
|
|||
|
|
return None
|
|||
|
|
return self._suggestion_to_dict(suggestion)
|
|||
|
|
|
|||
|
|
async def mark_read(self, suggestion_id: str, user_id: str) -> bool:
|
|||
|
|
"""标记建议为已读"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(Suggestion).where(
|
|||
|
|
Suggestion.id == suggestion_id,
|
|||
|
|
Suggestion.user_id == user_id
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
suggestion = result.scalar_one_or_none()
|
|||
|
|
if not suggestion:
|
|||
|
|
return False
|
|||
|
|
suggestion.is_read = True
|
|||
|
|
await self.db.commit()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
async def dismiss(self, suggestion_id: str, user_id: str) -> bool:
|
|||
|
|
"""忽略建议"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(Suggestion).where(
|
|||
|
|
Suggestion.id == suggestion_id,
|
|||
|
|
Suggestion.user_id == user_id
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
suggestion = result.scalar_one_or_none()
|
|||
|
|
if not suggestion:
|
|||
|
|
return False
|
|||
|
|
suggestion.is_dismissed = True
|
|||
|
|
await self.db.commit()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def _suggestion_to_dict(self, suggestion: Suggestion) -> dict:
|
|||
|
|
return {
|
|||
|
|
"id": suggestion.id,
|
|||
|
|
"suggestion_type": suggestion.suggestion_type,
|
|||
|
|
"title": suggestion.title,
|
|||
|
|
"content": suggestion.content,
|
|||
|
|
"source_data": suggestion.source_data,
|
|||
|
|
"is_read": suggestion.is_read,
|
|||
|
|
"is_dismissed": suggestion.is_dismissed,
|
|||
|
|
"created_at": suggestion.created_at.isoformat() if suggestion.created_at else None
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: 创建 InteractiveService
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/services/interactive_service.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 interactive_service.py**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
from sqlalchemy import select, desc
|
|||
|
|
from app.models.interactive_topic import InteractiveTopic
|
|||
|
|
from app.core.llm import get_llm_client
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
|
|||
|
|
class InteractiveService:
|
|||
|
|
def __init__(self, db: AsyncSession):
|
|||
|
|
self.db = db
|
|||
|
|
self.llm = get_llm_client()
|
|||
|
|
|
|||
|
|
async def get_topics(self, user_id: str) -> dict:
|
|||
|
|
"""获取交互主题列表"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(InteractiveTopic)
|
|||
|
|
.where(InteractiveTopic.user_id == user_id)
|
|||
|
|
.order_by(desc(InteractiveTopic.created_at))
|
|||
|
|
)
|
|||
|
|
topics = result.scalars().all()
|
|||
|
|
|
|||
|
|
user_initiated = [t for t in topics if t.source == "user_initiated"]
|
|||
|
|
ai_proactive = [t for t in topics if t.source == "ai_proactive"]
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"user_initiated": [self._topic_to_dict(t) for t in user_initiated],
|
|||
|
|
"ai_proactive": [self._topic_to_dict(t) for t in ai_proactive]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def initiate_learning(self, user_id: str, topic: str) -> dict:
|
|||
|
|
"""用户发起学习"""
|
|||
|
|
interactive_topic = InteractiveTopic(
|
|||
|
|
user_id=user_id,
|
|||
|
|
topic=topic,
|
|||
|
|
status="pending",
|
|||
|
|
source="user_initiated"
|
|||
|
|
)
|
|||
|
|
self.db.add(interactive_topic)
|
|||
|
|
await self.db.commit()
|
|||
|
|
await self.db.refresh(interactive_topic)
|
|||
|
|
|
|||
|
|
# 触发异步学习(实际实现中可能用后台任务)
|
|||
|
|
# 这里简化为直接返回
|
|||
|
|
return self._topic_to_dict(interactive_topic)
|
|||
|
|
|
|||
|
|
async def get_topic_detail(self, topic_id: str, user_id: str) -> dict:
|
|||
|
|
"""获取主题详情"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(InteractiveTopic).where(
|
|||
|
|
InteractiveTopic.id == topic_id,
|
|||
|
|
InteractiveTopic.user_id == user_id
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
topic = result.scalar_one_or_none()
|
|||
|
|
if not topic:
|
|||
|
|
return None
|
|||
|
|
return self._topic_to_dict(topic)
|
|||
|
|
|
|||
|
|
def _topic_to_dict(self, topic: InteractiveTopic) -> dict:
|
|||
|
|
return {
|
|||
|
|
"id": topic.id,
|
|||
|
|
"topic": topic.topic,
|
|||
|
|
"status": topic.status,
|
|||
|
|
"result": topic.result,
|
|||
|
|
"kg_nodes_created": topic.kg_nodes_created,
|
|||
|
|
"source": topic.source,
|
|||
|
|
"created_at": topic.created_at.isoformat() if topic.created_at else None,
|
|||
|
|
"completed_at": topic.completed_at.isoformat() if topic.completed_at else None
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: 修改 Forum Router
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/app/routers/forum.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加新接口**
|
|||
|
|
|
|||
|
|
在文件开头添加导入:
|
|||
|
|
```python
|
|||
|
|
from app.schemas.learning import LearningSummaryOut, LearningHistoryOut
|
|||
|
|
from app.schemas.suggestion import SuggestionOut, SuggestionListOut
|
|||
|
|
from app.schemas.interactive import InteractiveTopicOut, InteractiveTopicsOut, LearnRequest, LearnResponse
|
|||
|
|
from app.services.learning_service import LearningService
|
|||
|
|
from app.services.suggestion_service import SuggestionService
|
|||
|
|
from app.services.interactive_service import InteractiveService
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 添加 Learning 接口**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@router.get("/learning/summary", response_model=LearningSummaryOut)
|
|||
|
|
async def get_learning_summary(
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = LearningService(db)
|
|||
|
|
return await service.get_summary(current_user.id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/learning/history", response_model=LearningHistoryOut)
|
|||
|
|
async def get_learning_history(
|
|||
|
|
page: int = 1,
|
|||
|
|
limit: int = 20,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = LearningService(db)
|
|||
|
|
return await service.get_history(current_user.id, page, limit)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 添加 Suggestion 接口**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@router.get("/suggestions", response_model= dict)
|
|||
|
|
async def list_suggestions(
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = SuggestionService(db)
|
|||
|
|
suggestions = await service.get_suggestions(current_user.id)
|
|||
|
|
return {"suggestions": suggestions}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/suggestions/{suggestion_id}", response_model=SuggestionOut)
|
|||
|
|
async def get_suggestion(
|
|||
|
|
suggestion_id: str,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = SuggestionService(db)
|
|||
|
|
suggestion = await service.get_suggestion(suggestion_id, current_user.id)
|
|||
|
|
if not suggestion:
|
|||
|
|
raise HTTPException(status_code=404, detail="建议不存在")
|
|||
|
|
return suggestion
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.patch("/suggestions/{suggestion_id}/read")
|
|||
|
|
async def mark_suggestion_read(
|
|||
|
|
suggestion_id: str,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = SuggestionService(db)
|
|||
|
|
success = await service.mark_read(suggestion_id, current_user.id)
|
|||
|
|
if not success:
|
|||
|
|
raise HTTPException(status_code=404, detail="建议不存在")
|
|||
|
|
return {"status": "ok"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.delete("/suggestions/{suggestion_id}/dismiss")
|
|||
|
|
async def dismiss_suggestion(
|
|||
|
|
suggestion_id: str,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = SuggestionService(db)
|
|||
|
|
success = await service.dismiss(suggestion_id, current_user.id)
|
|||
|
|
if not success:
|
|||
|
|
raise HTTPException(status_code=404, detail="建议不存在")
|
|||
|
|
return {"status": "ok"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 添加 Interactive 接口**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@router.get("/interactive/topics", response_model=InteractiveTopicsOut)
|
|||
|
|
async def get_interactive_topics(
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = InteractiveService(db)
|
|||
|
|
return await service.get_topics(current_user.id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/interactive/learn", response_model=InteractiveTopicOut)
|
|||
|
|
async def initiate_learning(
|
|||
|
|
data: LearnRequest,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = InteractiveService(db)
|
|||
|
|
result = await service.initiate_learning(current_user.id, data.topic)
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/interactive/topics/{topic_id}", response_model=InteractiveTopicOut)
|
|||
|
|
async def get_interactive_topic(
|
|||
|
|
topic_id: str,
|
|||
|
|
current_user: User = Depends(get_current_user),
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
):
|
|||
|
|
service = InteractiveService(db)
|
|||
|
|
topic = await service.get_topic_detail(topic_id, current_user.id)
|
|||
|
|
if not topic:
|
|||
|
|
raise HTTPException(status_code=404, detail="主题不存在")
|
|||
|
|
return topic
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 9: 更新前端 API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/api/forum.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加新类型和API方法**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Types
|
|||
|
|
export interface LearningRecord {
|
|||
|
|
id: string
|
|||
|
|
learning_type: 'concept' | 'technology' | 'workflow'
|
|||
|
|
topic: string
|
|||
|
|
summary: string
|
|||
|
|
source: string
|
|||
|
|
source_ids?: { conversation_ids?: string[]; task_ids?: string[] }
|
|||
|
|
kg_nodes_created?: string[]
|
|||
|
|
created_at: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface LearningSummary {
|
|||
|
|
summary: string
|
|||
|
|
records: LearningRecord[]
|
|||
|
|
stats: {
|
|||
|
|
nodes_created: number
|
|||
|
|
edges_created: number
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface Suggestion {
|
|||
|
|
id: string
|
|||
|
|
suggestion_type: 'knowledge' | 'efficiency' | 'skill'
|
|||
|
|
title: string
|
|||
|
|
content: string
|
|||
|
|
source_data?: Record<string, any>
|
|||
|
|
is_read: boolean
|
|||
|
|
is_dismissed: boolean
|
|||
|
|
created_at: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface InteractiveTopic {
|
|||
|
|
id: string
|
|||
|
|
topic: string
|
|||
|
|
status: 'pending' | 'learning' | 'completed' | 'failed'
|
|||
|
|
result?: string
|
|||
|
|
kg_nodes_created?: string[]
|
|||
|
|
source: 'user_initiated' | 'ai_proactive'
|
|||
|
|
created_at: string
|
|||
|
|
completed_at?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// API methods
|
|||
|
|
export const forumApi = {
|
|||
|
|
// ... existing methods ...
|
|||
|
|
|
|||
|
|
// Learning
|
|||
|
|
fetchLearningSummary() {
|
|||
|
|
return api.get<LearningSummary>('/api/forum/learning/summary')
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
fetchLearningHistory(params: { page: number, limit: number }) {
|
|||
|
|
return api.get<{ records: LearningRecord[], total: number }>('/api/forum/learning/history', { params })
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// Suggestions
|
|||
|
|
fetchSuggestions() {
|
|||
|
|
return api.get<{ suggestions: Suggestion[] }>('/api/forum/suggestions')
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getSuggestion(id: string) {
|
|||
|
|
return api.get<Suggestion>(`/api/forum/suggestions/${id}`)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
markSuggestionRead(id: string) {
|
|||
|
|
return api.patch(`/api/forum/suggestions/${id}/read`)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
dismissSuggestion(id: string) {
|
|||
|
|
return api.delete(`/api/forum/suggestions/${id}/dismiss`)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// Interactive
|
|||
|
|
fetchInteractiveTopics() {
|
|||
|
|
return api.get<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>('/api/forum/interactive/topics')
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
initiateLearning(topic: string) {
|
|||
|
|
return api.post<InteractiveTopic>('/api/forum/interactive/learn', { topic })
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getTopicDetail(id: string) {
|
|||
|
|
return api.get<InteractiveTopic>(`/api/forum/interactive/topics/${id}`)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 10: 创建 LearningSection 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/forum/LearningSection.vue`
|
|||
|
|
- Create: `frontend/src/components/forum/LearningSummaryCard.vue`
|
|||
|
|
- Create: `frontend/src/components/forum/LearningTimeline.vue`
|
|||
|
|
- Create: `frontend/src/components/forum/LearningStats.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 LearningSummaryCard.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { Brain, Clock } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
defineProps<{
|
|||
|
|
summary: string
|
|||
|
|
date: string
|
|||
|
|
}>()
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="summary-card">
|
|||
|
|
<div class="card-header">
|
|||
|
|
<Brain :size="18" class="icon" />
|
|||
|
|
<span class="label">TODAY'S LEARNING</span>
|
|||
|
|
<Clock :size="12" class="clock" />
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<p class="summary-text">{{ summary }}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.summary-card {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.icon { color: var(--accent-purple); }
|
|||
|
|
|
|||
|
|
.label {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 10px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--accent-purple);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.clock { color: var(--text-dim); margin-left: auto; }
|
|||
|
|
|
|||
|
|
.summary-text {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
line-height: 1.6;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 LearningTimeline.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { onMounted, ref } from 'vue'
|
|||
|
|
import { forumApi, type LearningRecord } from '@/api/forum'
|
|||
|
|
import { Timeline } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
const records = ref<LearningRecord[]>([])
|
|||
|
|
const loading = ref(true)
|
|||
|
|
|
|||
|
|
async function loadHistory() {
|
|||
|
|
try {
|
|||
|
|
const res = await forumApi.fetchLearningHistory({ page: 1, limit: 10 })
|
|||
|
|
records.value = res.data.records
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to load history:', e)
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDate(dateStr: string) {
|
|||
|
|
const d = new Date(dateStr)
|
|||
|
|
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getTypeIcon(type: string) {
|
|||
|
|
return type === 'concept' ? '💡' : type === 'technology' ? '⚙️' : '📋'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => loadHistory())
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="timeline-section">
|
|||
|
|
<div class="section-header">
|
|||
|
|
<Timeline :size="16" />
|
|||
|
|
<span>LEARNING HISTORY</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|||
|
|
<div v-else-if="records.length === 0" class="empty">No learning records yet</div>
|
|||
|
|
<div v-else class="timeline">
|
|||
|
|
<div v-for="record in records" :key="record.id" class="timeline-item">
|
|||
|
|
<div class="timeline-dot">{{ getTypeIcon(record.learning_type) }}</div>
|
|||
|
|
<div class="timeline-content">
|
|||
|
|
<div class="timeline-topic">{{ record.topic }}</div>
|
|||
|
|
<div class="timeline-summary">{{ record.summary }}</div>
|
|||
|
|
<div class="timeline-date">{{ formatDate(record.created_at) }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.timeline-section {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 10px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--accent-purple);
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.timeline { display: flex; flex-direction: column; gap: 12px; }
|
|||
|
|
|
|||
|
|
.timeline-item { display: flex; gap: 12px; }
|
|||
|
|
|
|||
|
|
.timeline-dot { font-size: 16px; flex-shrink: 0; }
|
|||
|
|
|
|||
|
|
.timeline-content { flex: 1; min-width: 0; }
|
|||
|
|
|
|||
|
|
.timeline-topic { font-size: 13px; color: var(--text-primary); font-weight: 500; }
|
|||
|
|
|
|||
|
|
.timeline-summary { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
|
|||
|
|
|
|||
|
|
.timeline-date { font-size: 10px; color: var(--text-dim); margin-top: 4px; }
|
|||
|
|
|
|||
|
|
.loading, .empty { font-size: 12px; color: var(--text-dim); padding: 20px; text-align: center; }
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 LearningStats.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
|
|||
|
|
|
|||
|
|
defineProps<{
|
|||
|
|
nodesCreated: number
|
|||
|
|
edgesCreated: number
|
|||
|
|
}>()
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="stats-card">
|
|||
|
|
<div class="stat-item">
|
|||
|
|
<span class="stat-value">{{ nodesCreated }}</span>
|
|||
|
|
<span class="stat-label">NODES CREATED</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="stat-item">
|
|||
|
|
<span class="stat-value">{{ edgesCreated }}</span>
|
|||
|
|
<span class="stat-label">EDGES CREATED</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.stats-card {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 24px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-item { display: flex; flex-direction: column; gap: 4px; }
|
|||
|
|
|
|||
|
|
.stat-value {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 24px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.stat-label {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 9px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 LearningSection.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted } from 'vue'
|
|||
|
|
import { forumApi, type LearningSummary } from '@/api/forum'
|
|||
|
|
import LearningSummaryCard from './LearningSummaryCard.vue'
|
|||
|
|
import LearningTimeline from './LearningTimeline.vue'
|
|||
|
|
import LearningStats from './LearningStats.vue'
|
|||
|
|
|
|||
|
|
const summary = ref<LearningSummary | null>(null)
|
|||
|
|
const loading = ref(true)
|
|||
|
|
|
|||
|
|
async function loadSummary() {
|
|||
|
|
try {
|
|||
|
|
const res = await forumApi.fetchLearningSummary()
|
|||
|
|
summary.value = res.data
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to load summary:', e)
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => loadSummary())
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<section class="learning-section">
|
|||
|
|
<div class="section-header">
|
|||
|
|
<span class="section-tag">MODEL LEARNING</span>
|
|||
|
|
<h2>AI学习板块</h2>
|
|||
|
|
<p class="section-desc">AI分析你的活动,学习知识并汇报</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|||
|
|
<template v-else-if="summary">
|
|||
|
|
<LearningSummaryCard :summary="summary.summary" :date="new Date().toISOString()" />
|
|||
|
|
<LearningStats :nodes-created="summary.stats.nodes_created" :edges-created="summary.stats.edges_created" />
|
|||
|
|
<LearningTimeline />
|
|||
|
|
</template>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.learning-section {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-header { margin-bottom: 8px; }
|
|||
|
|
|
|||
|
|
.section-tag {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 9px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--accent-purple);
|
|||
|
|
background: var(--accent-purple-dim);
|
|||
|
|
padding: 2px 8px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h2 {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 18px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
margin: 8px 0 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-desc { font-size: 12px; color: var(--text-dim); }
|
|||
|
|
|
|||
|
|
.loading { color: var(--text-dim); padding: 20px; text-align: center; }
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 11: 创建 SuggestionSection 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/forum/SuggestionSection.vue`
|
|||
|
|
- Create: `frontend/src/components/forum/SuggestionCard.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 SuggestionCard.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { Lightbulb, TrendingUp, Clock, X } from 'lucide-vue-next'
|
|||
|
|
import type { Suggestion } from '@/api/forum'
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
suggestion: Suggestion
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
dismiss: [id: string]
|
|||
|
|
read: [id: string]
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const icons = {
|
|||
|
|
knowledge: Lightbulb,
|
|||
|
|
efficiency: TrendingUp,
|
|||
|
|
skill: Clock
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const colors = {
|
|||
|
|
knowledge: 'var(--accent-cyan)',
|
|||
|
|
efficiency: 'var(--accent-green)',
|
|||
|
|
skill: 'var(--accent-amber)'
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="suggestion-card" :class="{ read: suggestion.is_read }">
|
|||
|
|
<div class="card-icon" :style="{ color: colors[suggestion.suggestion_type] }">
|
|||
|
|
<component :is="icons[suggestion.suggestion_type]" :size="20" />
|
|||
|
|
</div>
|
|||
|
|
<div class="card-content">
|
|||
|
|
<div class="card-type">{{ suggestion.suggestion_type.toUpperCase() }}</div>
|
|||
|
|
<div class="card-title">{{ suggestion.title }}</div>
|
|||
|
|
<div class="card-body">{{ suggestion.content }}</div>
|
|||
|
|
</div>
|
|||
|
|
<button class="dismiss-btn" @click="emit('dismiss', suggestion.id)" title="忽略">
|
|||
|
|
<X :size="14" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.suggestion-card {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding: 16px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.suggestion-card:hover { border-color: var(--border-mid); }
|
|||
|
|
|
|||
|
|
.suggestion-card.read { opacity: 0.6; }
|
|||
|
|
|
|||
|
|
.card-icon { flex-shrink: 0; }
|
|||
|
|
|
|||
|
|
.card-content { flex: 1; min-width: 0; }
|
|||
|
|
|
|||
|
|
.card-type {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 8px;
|
|||
|
|
letter-spacing: 0.1em;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-title {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-weight: 500;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card-body { font-size: 11px; color: var(--text-secondary); line-height: 1.5; }
|
|||
|
|
|
|||
|
|
.dismiss-btn {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 4px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dismiss-btn:hover { color: var(--accent-red); background: rgba(255, 71, 87, 0.1); }
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 SuggestionSection.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted } from 'vue'
|
|||
|
|
import { forumApi, type Suggestion } from '@/api/forum'
|
|||
|
|
import SuggestionCard from './SuggestionCard.vue'
|
|||
|
|
import { MessageCircle } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
const suggestions = ref<Suggestion[]>([])
|
|||
|
|
const loading = ref(true)
|
|||
|
|
|
|||
|
|
async function loadSuggestions() {
|
|||
|
|
try {
|
|||
|
|
const res = await forumApi.fetchSuggestions()
|
|||
|
|
suggestions.value = res.data.suggestions
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to load suggestions:', e)
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDismiss(id: string) {
|
|||
|
|
await forumApi.dismissSuggestion(id)
|
|||
|
|
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => loadSuggestions())
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<section class="suggestion-section">
|
|||
|
|
<div class="section-header">
|
|||
|
|
<span class="section-tag">SUGGESTIONS</span>
|
|||
|
|
<h2>AI建议板块</h2>
|
|||
|
|
<p class="section-desc">基于你的习惯提供个性化建议</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|||
|
|
<div v-else-if="suggestions.length === 0" class="empty">
|
|||
|
|
<MessageCircle :size="32" />
|
|||
|
|
<span>No suggestions yet</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="suggestion-list">
|
|||
|
|
<SuggestionCard
|
|||
|
|
v-for="s in suggestions"
|
|||
|
|
:key="s.id"
|
|||
|
|
:suggestion="s"
|
|||
|
|
@dismiss="handleDismiss"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.suggestion-section { display: flex; flex-direction: column; gap: 16px; }
|
|||
|
|
|
|||
|
|
.section-header { margin-bottom: 8px; }
|
|||
|
|
|
|||
|
|
.section-tag {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 9px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--accent-amber);
|
|||
|
|
background: rgba(251, 191, 36, 0.1);
|
|||
|
|
padding: 2px 8px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h2 {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 18px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
margin: 8px 0 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-desc { font-size: 12px; color: var(--text-dim); }
|
|||
|
|
|
|||
|
|
.suggestion-list { display: flex; flex-direction: column; gap: 12px; }
|
|||
|
|
|
|||
|
|
.loading, .empty {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 40px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 12: 创建 InteractiveSection 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/forum/InteractiveSection.vue`
|
|||
|
|
- Create: `frontend/src/components/forum/LearningInput.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 LearningInput.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref } from 'vue'
|
|||
|
|
import { Send, Sparkles } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
submit: [topic: string]
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const topic = ref('')
|
|||
|
|
const loading = ref(false)
|
|||
|
|
|
|||
|
|
async function handleSubmit() {
|
|||
|
|
if (!topic.value.trim() || loading.value) return
|
|||
|
|
loading.value = true
|
|||
|
|
emit('submit', topic.value.trim())
|
|||
|
|
topic.value = ''
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="learning-input">
|
|||
|
|
<div class="input-wrapper">
|
|||
|
|
<Sparkles :size="16" class="sparkle-icon" />
|
|||
|
|
<input
|
|||
|
|
v-model="topic"
|
|||
|
|
type="text"
|
|||
|
|
placeholder="让AI学习 [主题]..."
|
|||
|
|
class="topic-input"
|
|||
|
|
@keyup.enter="handleSubmit"
|
|||
|
|
/>
|
|||
|
|
<button class="submit-btn" @click="handleSubmit" :disabled="!topic.trim() || loading">
|
|||
|
|
<Send :size="14" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.learning-input { margin-bottom: 16px; }
|
|||
|
|
|
|||
|
|
.input-wrapper {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-wrapper:focus-within {
|
|||
|
|
border-color: var(--accent-purple);
|
|||
|
|
box-shadow: 0 0 0 2px var(--accent-purple-dim);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sparkle-icon { color: var(--accent-purple); flex-shrink: 0; }
|
|||
|
|
|
|||
|
|
.topic-input {
|
|||
|
|
flex: 1;
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topic-input::placeholder { color: var(--text-dim); }
|
|||
|
|
|
|||
|
|
.submit-btn {
|
|||
|
|
background: var(--accent-purple-dim);
|
|||
|
|
border: 1px solid rgba(123, 44, 191, 0.3);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
color: var(--accent-purple);
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.submit-btn:hover:not(:disabled) {
|
|||
|
|
background: rgba(123, 44, 191, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 InteractiveSection.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted } from 'vue'
|
|||
|
|
import { forumApi, type InteractiveTopic } from '@/api/forum'
|
|||
|
|
import LearningInput from './LearningInput.vue'
|
|||
|
|
import { User, Bot, RefreshCw, CheckCircle } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
const userTopics = ref<InteractiveTopic[]>([])
|
|||
|
|
const aiTopics = ref<InteractiveTopic[]>([])
|
|||
|
|
const loading = ref(true)
|
|||
|
|
|
|||
|
|
async function loadTopics() {
|
|||
|
|
try {
|
|||
|
|
const res = await forumApi.fetchInteractiveTopics()
|
|||
|
|
userTopics.value = res.data.user_initiated
|
|||
|
|
aiTopics.value = res.data.ai_proactive
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to load topics:', e)
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleInitiateLearn(topic: string) {
|
|||
|
|
try {
|
|||
|
|
await forumApi.initiateLearning(topic)
|
|||
|
|
await loadTopics()
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to initiate learning:', e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getStatusIcon(status: string) {
|
|||
|
|
switch (status) {
|
|||
|
|
case 'completed': return CheckCircle
|
|||
|
|
case 'learning': return RefreshCw
|
|||
|
|
default: return RefreshCw
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getStatusColor(status: string) {
|
|||
|
|
switch (status) {
|
|||
|
|
case 'completed': return 'var(--accent-green)'
|
|||
|
|
case 'failed': return 'var(--accent-red)'
|
|||
|
|
default: return 'var(--text-dim)'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => loadTopics())
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<section class="interactive-section">
|
|||
|
|
<div class="section-header">
|
|||
|
|
<span class="section-tag">INTERACTIVE</span>
|
|||
|
|
<h2>AI交互板块</h2>
|
|||
|
|
<p class="section-desc">用户发起学习主题,AI主动探索</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<LearningInput @submit="handleInitiateLearn" />
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|||
|
|
<template v-else>
|
|||
|
|
<!-- User Initiated -->
|
|||
|
|
<div class="subsection">
|
|||
|
|
<div class="subsection-header">
|
|||
|
|
<User :size="14" />
|
|||
|
|
<span>USER INITIATED</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="userTopics.length === 0" class="empty-sub">No user-initiated topics</div>
|
|||
|
|
<div v-else class="topic-list">
|
|||
|
|
<div v-for="topic in userTopics" :key="topic.id" class="topic-item">
|
|||
|
|
<component :is="getStatusIcon(topic.status)" :size="14" :style="{ color: getStatusColor(topic.status) }" />
|
|||
|
|
<span class="topic-name">{{ topic.topic }}</span>
|
|||
|
|
<span class="topic-status">{{ topic.status }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- AI Proactive -->
|
|||
|
|
<div class="subsection">
|
|||
|
|
<div class="subsection-header">
|
|||
|
|
<Bot :size="14" />
|
|||
|
|
<span>AI PROACTIVE</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="aiTopics.length === 0" class="empty-sub">No AI proactive topics</div>
|
|||
|
|
<div v-else class="topic-list">
|
|||
|
|
<div v-for="topic in aiTopics" :key="topic.id" class="topic-item">
|
|||
|
|
<component :is="getStatusIcon(topic.status)" :size="14" :style="{ color: getStatusColor(topic.status) }" />
|
|||
|
|
<span class="topic-name">{{ topic.topic }}</span>
|
|||
|
|
<span class="topic-status">{{ topic.status }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.interactive-section { display: flex; flex-direction: column; gap: 16px; }
|
|||
|
|
|
|||
|
|
.section-header { margin-bottom: 8px; }
|
|||
|
|
|
|||
|
|
.section-tag {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 9px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
background: var(--accent-cyan-dim);
|
|||
|
|
padding: 2px 8px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h2 {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 18px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
margin: 8px 0 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.section-desc { font-size: 12px; color: var(--text-dim); }
|
|||
|
|
|
|||
|
|
.subsection {
|
|||
|
|
background: var(--bg-card);
|
|||
|
|
border: 1px solid var(--border-dim);
|
|||
|
|
border-radius: var(--radius-lg);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.subsection-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 9px;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topic-list { display: flex; flex-direction: column; gap: 8px; }
|
|||
|
|
|
|||
|
|
.topic-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 8px;
|
|||
|
|
background: var(--bg-panel);
|
|||
|
|
border-radius: var(--radius-md);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.topic-name { flex: 1; font-size: 12px; color: var(--text-primary); }
|
|||
|
|
|
|||
|
|
.topic-status {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 10px;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty-sub { font-size: 11px; color: var(--text-dim); }
|
|||
|
|
|
|||
|
|
.loading { color: var(--text-dim); padding: 20px; text-align: center; }
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 13: 重写 ForumView.vue
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/views/ForumView.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 重写 ForumView.vue**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import LearningSection from '@/components/forum/LearningSection.vue'
|
|||
|
|
import SuggestionSection from '@/components/forum/SuggestionSection.vue'
|
|||
|
|
import InteractiveSection from '@/components/forum/InteractiveSection.vue'
|
|||
|
|
import { Radio } from 'lucide-vue-next'
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="forum-view">
|
|||
|
|
<!-- Header -->
|
|||
|
|
<div class="page-header">
|
|||
|
|
<div class="header-left">
|
|||
|
|
<div class="header-icon"><Radio :size="20" /></div>
|
|||
|
|
<div class="header-text">
|
|||
|
|
<h1>INTERACTIVE PLAZA</h1>
|
|||
|
|
<span class="header-sub">AI-driven learning & suggestions</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Three Sections -->
|
|||
|
|
<div class="sections-container">
|
|||
|
|
<LearningSection />
|
|||
|
|
<SuggestionSection />
|
|||
|
|
<InteractiveSection />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.forum-view {
|
|||
|
|
height: 100%;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 24px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 32px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.page-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-left { display: flex; align-items: center; gap: 14px; }
|
|||
|
|
|
|||
|
|
.header-icon { color: var(--accent-purple); filter: drop-shadow(0 0 8px var(--accent-purple)); }
|
|||
|
|
|
|||
|
|
h1 {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 20px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: 0.15em;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
|
|||
|
|
|
|||
|
|
.sections-container {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 24px;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 14: 验证和测试
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 后端语法检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd backend && python -m py_compile app/models/learning_record.py app/models/suggestion.py app/models/interactive_topic.py app/services/learning_service.py app/services/suggestion_service.py app/services/interactive_service.py app/routers/forum.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 前端 TypeScript 检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend && npx vue-tsc --noEmit
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 启动服务测试**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 后端
|
|||
|
|
cd backend && python -m uvicorn app.main:app --reload
|
|||
|
|
|
|||
|
|
# 前端
|
|||
|
|
cd frontend && npm run dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 执行选项
|
|||
|
|
|
|||
|
|
**1. Subagent-Driven (推荐)** - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
|
|||
|
|
|
|||
|
|
**2. Inline Execution** - 在当前会话中按批次执行任务
|
|||
|
|
|
|||
|
|
选择哪种方式?
|