Files
JARVIS/docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md

1612 lines
42 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 (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** - 在当前会话中按批次执行任务
选择哪种方式?