2991692032 1 week ago
parent 72d0cf16c4
commit 7170c136a8

@ -1,396 +0,0 @@
# AI助手后端接口文档
## 重要提示 - 分类API更新需求
前端已修复分类数量显示问题需要后端配合更新以下API
### 论坛分类API (`/api/categories`)
返回的分类数据需要包含 `postCount` 字段:
```json
{
"code": 200,
"data": {
"list": [
{
"id": 1,
"name": "学术交流",
"description": "学术讨论与交流",
"postCount": 15, // ← 新增:该分类下的帖子数量
"status": 1,
"createdAt": "2024-12-21T10:00:00Z"
}
]
}
}
```
### 资源分类API (`/api/categories`)
当用于资源模块时,需要包含 `resourceCount` 字段:
```json
{
"code": 200,
"data": {
"list": [
{
"id": 1,
"name": "学术资料",
"description": "学术相关资源",
"resourceCount": 8, // ← 新增:该分类下的资源数量
"status": 1,
"createdAt": "2024-12-21T10:00:00Z"
}
]
}
}
```
---
## 概述
AI助手功能为UniLife平台提供智能问答服务支持多轮对话、会话管理、历史记录等功能。
## 基础信息
- **基础URL**: `/api/ai`
- **认证方式**: JWT Token (Header: `Authorization: Bearer <token>`)
- **响应格式**: JSON
## 通用响应格式
```json
{
"code": 200,
"message": "success",
"data": { ... },
"timestamp": "2024-12-21T10:30:00Z"
}
```
## 数据模型
### ChatMessage 聊天消息
```json
{
"id": "string",
"sessionId": "string",
"role": "user|assistant|system",
"content": "string",
"timestamp": "2024-12-21T10:30:00Z",
"userId": "number"
}
```
### ChatSession 聊天会话
```json
{
"id": "string",
"userId": "number",
"title": "string",
"createdAt": "2024-12-21T10:30:00Z",
"updatedAt": "2024-12-21T10:30:00Z",
"lastMessageTime": "2024-12-21T10:30:00Z",
"messageCount": "number"
}
```
## API接口
### 1. 发送消息 (核心接口)
**POST** `/ai/chat`
向AI发送消息并获取回复。
**请求体:**
```json
{
"message": "如何学习Java编程?",
"sessionId": "session_123", // 可选,不传则创建新会话
"conversationHistory": [ // 可选,用于上下文
{
"role": "user",
"content": "之前的问题",
"timestamp": "2024-12-21T10:25:00Z"
}
]
}
```
**响应:**
```json
{
"code": 200,
"message": "success",
"data": {
"messageId": "msg_456",
"content": "学习Java编程可以从以下几个方面开始:\n\n1. **基础语法**\n - 变量和数据类型\n - 控制流程\n - 面向对象\n\n2. **实践项目**\n - 简单的控制台程序\n - Web应用开发\n\n```java\npublic class HelloWorld {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}\n```\n\n希望这个建议对你有帮助",
"sessionId": "session_123",
"timestamp": "2024-12-21T10:30:00Z"
}
}
```
### 2. 获取聊天会话列表
**GET** `/ai/sessions`
获取用户的所有聊天会话。
**查询参数:**
- `page`: number (默认: 1)
- `size`: number (默认: 20)
**响应:**
```json
{
"code": 200,
"data": {
"sessions": [
{
"id": "session_123",
"userId": 1,
"title": "Java学习咨询",
"createdAt": "2024-12-21T10:00:00Z",
"updatedAt": "2024-12-21T10:30:00Z",
"lastMessageTime": "2024-12-21T10:30:00Z",
"messageCount": 5
}
],
"total": 10
}
}
```
### 3. 获取会话消息历史
**GET** `/ai/sessions/{sessionId}/messages`
获取指定会话的消息历史。
**路径参数:**
- `sessionId`: string - 会话ID
**查询参数:**
- `page`: number (默认: 1)
- `size`: number (默认: 50)
**响应:**
```json
{
"code": 200,
"data": {
"messages": [
{
"id": "msg_001",
"sessionId": "session_123",
"role": "user",
"content": "如何学习Java编程?",
"timestamp": "2024-12-21T10:25:00Z",
"userId": 1
},
{
"id": "msg_002",
"sessionId": "session_123",
"role": "assistant",
"content": "学习Java编程可以从基础语法开始...",
"timestamp": "2024-12-21T10:30:00Z",
"userId": null
}
],
"total": 5,
"sessionInfo": {
"id": "session_123",
"title": "Java学习咨询",
"createdAt": "2024-12-21T10:00:00Z"
}
}
}
```
### 4. 创建新的聊天会话
**POST** `/ai/sessions`
创建一个新的聊天会话。
**请求体:**
```json
{
"title": "新的聊天" // 可选,不传则自动生成
}
```
**响应:**
```json
{
"code": 200,
"data": {
"sessionId": "session_456",
"title": "新的聊天"
}
}
```
### 5. 更新会话标题
**PUT** `/ai/sessions/{sessionId}`
更新指定会话的标题。
**路径参数:**
- `sessionId`: string
**请求体:**
```json
{
"title": "Java学习讨论"
}
```
**响应:**
```json
{
"code": 200,
"message": "会话标题更新成功"
}
```
### 6. 删除聊天会话
**DELETE** `/ai/sessions/{sessionId}`
删除指定的聊天会话及其所有消息。
**路径参数:**
- `sessionId`: string
**响应:**
```json
{
"code": 200,
"message": "会话删除成功"
}
```
### 7. 清空会话消息
**DELETE** `/ai/sessions/{sessionId}/messages`
清空指定会话的所有消息,但保留会话。
**路径参数:**
- `sessionId`: string
**响应:**
```json
{
"code": 200,
"message": "会话消息已清空"
}
```
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 400 | 请求参数错误 |
| 401 | 未授权访问 |
| 403 | 无权限访问 |
| 404 | 会话或消息不存在 |
| 429 | 请求频率过高 |
| 500 | AI服务异常 |
| 503 | AI服务暂时不可用 |
## 实现建议
### 1. 数据库设计
**ai_sessions表:**
```sql
CREATE TABLE ai_sessions (
id VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_message_time TIMESTAMP,
message_count INT DEFAULT 0,
INDEX idx_user_id (user_id),
INDEX idx_updated_at (updated_at)
);
```
**ai_messages表:**
```sql
CREATE TABLE ai_messages (
id VARCHAR(64) PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
user_id BIGINT,
role ENUM('user', 'assistant', 'system') NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_id (session_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (session_id) REFERENCES ai_sessions(id) ON DELETE CASCADE
);
```
### 2. AI服务集成
建议集成以下AI服务之一
- **OpenAI GPT API** - 功能强大,支持多种模型
- **百度文心一言** - 中文优化,国内访问稳定
- **阿里通义千问** - 企业级服务,安全可靠
- **智谱GLM** - 性价比高,中文效果好
### 3. 关键功能
1. **上下文管理**: 维护对话历史,提供连贯的多轮对话
2. **流式输出**: 支持Server-Sent Events实现打字机效果
3. **内容安全**: 过滤敏感内容,确保回复安全合规
4. **速率限制**: 防止滥用,控制每用户请求频率
5. **缓存机制**: 相似问题缓存回复,提高响应速度
### 4. 安全考虑
- 所有接口需要JWT认证
- 验证用户对会话的权限
- 过滤和审核用户输入
- 监控和记录异常使用行为
- 设置合理的内容长度限制
### 5. 性能优化
- 使用连接池管理AI服务连接
- 实现请求排队和负载均衡
- 添加Redis缓存热门问答
- 定期清理过期会话数据
- 监控API调用频率和成本
## 前端配合说明
前端已实现:
- ✅ 现代化聊天界面设计
- ✅ Markdown内容渲染
- ✅ 打字机效果动画
- ✅ 会话历史管理
- ✅ 响应式设计
- ✅ 示例问题快速发送
- ✅ 键盘快捷键支持
需要后端配合:
- 🔄 实现上述所有API接口
- 🔄 选择并集成AI服务
- 🔄 建立数据库表结构
- 🔄 添加安全和性能优化
## 测试建议
1. **单元测试**: 测试各API接口的基本功能
2. **集成测试**: 测试AI服务的集成和响应
3. **压力测试**: 测试并发请求的处理能力
4. **安全测试**: 验证权限控制和内容安全
5. **用户体验测试**: 测试实际使用场景和响应时间

@ -2,6 +2,16 @@
## 更新日志
### v1.3.0 (2025-01-27)
- **新增AI辅助学习模块**: 实现了完整的AI聊天助手功能
- **AI会话管理**: 支持创建、查询、更新、删除聊天会话
- **AI消息系统**: 支持发送消息、获取回复、查看历史记录
- **上下文感知**: AI可以根据会话历史提供连贯的对话体验
- **Markdown支持**: AI回复支持丰富的格式化内容
- **会话标题编辑**: 支持双击编辑或点击编辑按钮修改会话标题
- **数据库设计**: 新增ai_chat_sessions和ai_chat_messages表
- **多会话支持**: 用户可以同时进行多个独立的AI对话会话
### v1.2.0 (2025-01-27)
- **修复资源点赞功能**: 实现了完整的资源点赞表 (`resource_likes`),防止重复点赞
- **优化响应数据结构**: 统一时间格式为ISO 8601格式 (`yyyy-MM-ddTHH:mm:ss`)
@ -31,7 +41,8 @@
- [4.3 分类管理](#43-分类管理)
- [5. 学习资源共享模块](#5-学习资源共享模块)
- [6. 课程表与日程管理模块](#6-课程表与日程管理模块)
- [7. 待实现模块](#7-待实现模块)
- [7. AI辅助学习模块](#7-AI辅助学习模块)
- [8. 待实现模块](#8-待实现模块)
## 1. 基础信息
@ -1646,10 +1657,295 @@ CREATE TABLE `schedules` (
- 第12节课: 19:20-20:10
- 第13节课: 20:10-21:00
## 7. 待实现模块
## 7. AI辅助学习模块
### 7.1 核心接口
#### 7.1.1 发送消息给AI流式响应
- **URL**: `/ai/chat`
- **方法**: POST
- **描述**: 向AI发送消息并获取流式回复
- **认证**: 需要JWT Token
- **响应类型**: `text/event-stream`Server-Sent Events
请求参数:
```json
{
"message": "如何学好Python编程",
"sessionId": "session_1234567890",
"conversationHistory": [
{
"id": "msg_001",
"role": "user",
"content": "你好",
"timestamp": "2025-01-27T10:00:00"
}
]
}
```
**参数说明**:
- `message`: 用户发送的消息内容
- `sessionId` (可选): 会话ID如果不提供则创建新会话
- `conversationHistory` (可选): 会话历史记录用于AI理解上下文
**流式响应**:
接口返回Server-Sent Events流每个事件包含AI回复的一部分内容
```
data: 学好Python编程需要
data: 循序渐进地学习
data: ,首先掌握基础语法
data: ,然后通过实际项目练习
data: [END]
```
**前端处理示例**:
```javascript
const response = await fetch('/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(requestData)
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// 处理流式数据块
console.log(chunk);
}
```
#### 7.1.2 获取聊天会话列表
- **URL**: `/ai/sessions`
- **方法**: GET
- **描述**: 获取当前用户的聊天会话列表
- **认证**: 需要JWT Token
请求参数:
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为20
响应结果:
```json
{
"code": 200,
"message": "success",
"data": {
"sessions": [
{
"id": "session_1234567890",
"title": "Python学习咨询",
"createdAt": "2025-01-27T10:00:00",
"updatedAt": "2025-01-27T15:30:00",
"lastMessageTime": "2025-01-27T15:30:00",
"messageCount": 8
}
],
"total": 5
}
}
```
#### 7.1.3 获取会话消息历史
- **URL**: `/ai/sessions/{sessionId}/messages`
- **方法**: GET
- **描述**: 获取指定会话的消息历史
- **认证**: 需要JWT Token
请求参数:
- **page** (query, 可选): 页码默认为1
- **size** (query, 可选): 每页大小默认为50
响应结果:
```json
{
"code": 200,
"message": "success",
"data": {
"messages": [
{
"id": "msg_001",
"role": "user",
"content": "如何学好Python编程",
"timestamp": "2025-01-27T10:00:00"
},
{
"id": "msg_002",
"role": "assistant",
"content": "学好Python编程需要循序渐进...",
"timestamp": "2025-01-27T10:00:30"
}
],
"total": 8,
"sessionInfo": {
"id": "session_1234567890",
"title": "Python学习咨询",
"createdAt": "2025-01-27T10:00:00",
"updatedAt": "2025-01-27T15:30:00",
"lastMessageTime": "2025-01-27T15:30:00",
"messageCount": 8
}
}
}
```
#### 7.1.4 创建聊天会话
- **URL**: `/ai/sessions`
- **方法**: POST
- **描述**: 创建新的AI聊天会话sessionId由前端生成
- **认证**: 需要JWT Token
请求参数:
```json
{
"sessionId": "session_1706177600_abc123",
"title": "新对话"
}
```
**参数说明**:
- `sessionId`: 会话ID由前端生成格式session_<timestamp>_<random>
- `title` (可选): 会话标题,默认为"新对话"
响应结果:
```json
{
"code": 200,
"message": "创建会话成功",
"data": {
"sessionId": "session_1706177600_abc123",
"title": "新对话"
}
}
```
#### 7.1.5 更新会话标题
- **URL**: `/ai/sessions/{sessionId}`
- **方法**: PUT
- **描述**: 更新会话标题
- **认证**: 需要JWT Token
请求参数:
```json
{
"title": "更新后的标题"
}
```
响应结果:
```json
{
"code": 200,
"message": "更新成功",
"data": null
}
```
#### 7.1.6 清空会话消息
- **URL**: `/ai/sessions/{sessionId}/messages`
- **方法**: DELETE
- **描述**: 清空指定会话的所有消息(保留会话)
- **认证**: 需要JWT Token
响应结果:
```json
{
"code": 200,
"message": "清空成功",
"data": null
}
```
#### 7.1.7 删除会话
- **URL**: `/ai/sessions/{sessionId}`
- **方法**: DELETE
- **描述**: 删除指定会话及其所有消息
- **认证**: 需要JWT Token
响应结果:
```json
{
"code": 200,
"message": "删除成功",
"data": null
}
```
### 7.2 数据库设计说明
#### AI聊天会话表 (ai_chat_sessions)
```sql
CREATE TABLE `ai_chat_sessions` (
`id` VARCHAR(64) PRIMARY KEY COMMENT '会话ID前端生成',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`title` VARCHAR(100) NOT NULL DEFAULT '新对话' COMMENT '会话标题',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_user_id` (`user_id`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
```
#### AI聊天消息表 (ai_chat_messages)
```sql
CREATE TABLE `ai_chat_messages` (
`id` VARCHAR(64) PRIMARY KEY COMMENT '消息ID前端生成',
`session_id` VARCHAR(64) NOT NULL COMMENT '会话ID',
`role` ENUM('user', 'assistant', 'system') NOT NULL COMMENT '角色',
`content` TEXT NOT NULL COMMENT '消息内容',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX `idx_session_id` (`session_id`),
FOREIGN KEY (`session_id`) REFERENCES `ai_chat_sessions` (`id`) ON DELETE CASCADE
);
```
### 7.3 流式响应特性说明
**核心特性**:
- 支持多会话管理,每个用户可以有多个独立的对话会话
- 消息按时间顺序存储,支持完整的对话历史记录
- 级联删除保证数据一致性
- 支持用户、助手和系统三种角色的消息
- 会话ID和消息ID由前端生成确保前端能够立即使用
- ID格式session_<timestamp>_<random> 和 msg_<timestamp>_<random>
- **流式响应**: 使用Server-Sent Events实现实时的AI回复流式传输
**前端流式处理示例**:
```typescript
import { sendMessage } from '@/api/ai'
// 使用流式API
await sendMessage({
message: '你好',
sessionId: 'session_123',
conversationHistory: []
}, (chunk: string) => {
// 处理每个数据块
currentMessage.content += chunk
// 实时更新UI
updateMessageDisplay()
})
```
**后端流式实现**:
```java
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> sendMessage(@RequestBody AiSendMessageDTO sendMessageDTO) {
return aiService.sendMessage(sendMessageDTO);
}
```
## 8. 待实现模块
以下模块尚未实现,将在后续开发中完成:
- 搜索功能模块
- AI辅助学习模块
- 积分系统模块

@ -24,13 +24,6 @@ export interface SendMessageRequest {
conversationHistory?: ChatMessage[]
}
export interface SendMessageResponse {
messageId: string
content: string
sessionId: string
timestamp: string
}
export interface ChatHistoryResponse {
sessions: ChatSession[]
total: number
@ -42,9 +35,30 @@ export interface ChatMessagesResponse {
sessionInfo: ChatSession
}
// 发送消息给AI
export const sendMessage = (data: SendMessageRequest) => {
return api.post<ApiResponse<SendMessageResponse>>('/ai/chat', data)
// 发送消息给AI流式响应
export const sendMessage = async (
data: SendMessageRequest
): Promise<ReadableStreamDefaultReader<Uint8Array>> => {
const token = localStorage.getItem('token')
const response = await fetch(`${api.defaults.baseURL}/ai/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
if (!response.body) {
throw new Error('Response body is not readable')
}
return response.body.getReader()
}
// 获取聊天历史列表
@ -62,8 +76,11 @@ export const getChatMessages = (sessionId: string, page = 1, size = 50) => {
}
// 创建新的聊天会话
export const createChatSession = (title?: string) => {
return api.post<ApiResponse<{ sessionId: string; title: string }>>('/ai/sessions', { title })
export const createChatSession = (sessionId: string, title?: string) => {
return api.post<ApiResponse<{ sessionId: string; title: string }>>('/ai/sessions', {
sessionId,
title
})
}
// 删除聊天会话

@ -0,0 +1,172 @@
<template>
<nav class="top-navbar">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname || '用户' }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { Setting } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
} else if (command === 'profile') {
router.push('/profile')
}
}
</script>
<style scoped>
.top-navbar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 16px 0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.brand-name {
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: #6b7280;
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: all 0.3s ease;
}
.nav-item:hover {
color: #374151;
background: rgba(107, 114, 128, 0.1);
}
.nav-item.router-link-active {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: #374151;
}
/* 响应式设计 */
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.nav-container {
padding: 0 16px;
}
.brand-name {
font-size: 20px;
}
.logo-circle {
width: 36px;
height: 36px;
font-size: 18px;
}
}
</style>

@ -58,7 +58,7 @@ const router = createRouter({
{
path: '/ai-assistant',
name: 'ai-assistant',
component: () => import('@/views/AIAssistantView.vue'),
component: () => import('@/views/ai/AIAssistantView.vue'),
meta: { requiresAuth: true }
},
{

@ -0,0 +1,19 @@
/**
* ID
* session_<timestamp>_<random>
*/
export const generateSessionId = (): string => {
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
return `session_${timestamp}_${random}`
}
/**
* ID
* msg_<timestamp>_<random>
*/
export const generateMessageId = (): string => {
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
return `msg_${timestamp}_${random}`
}

@ -1,864 +0,0 @@
<template>
<div class="ai-assistant-container">
<!-- 顶部导航栏 -->
<nav class="ai-navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item active">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 主要内容区域 -->
<div class="ai-main">
<div class="ai-content">
<!-- 侧边栏 - 聊天历史 -->
<aside class="chat-sidebar card-light">
<div class="sidebar-header">
<h3>聊天记录</h3>
<el-button
@click="startNewChat"
type="primary"
size="small"
class="new-chat-btn"
>
<el-icon><Plus /></el-icon>
新对话
</el-button>
</div>
<div class="chat-history">
<div
v-for="chat in chatHistory"
:key="chat.id"
class="chat-item"
:class="{ active: currentChatId === chat.id }"
@click="selectChat(chat.id)"
>
<div class="chat-title">{{ chat.title }}</div>
<div class="chat-time">{{ chat.lastMessageTime }}</div>
</div>
<!-- 空状态 -->
<div v-if="chatHistory.length === 0" class="empty-history">
<el-icon class="empty-icon"><ChatDotRound /></el-icon>
<p>暂无聊天记录</p>
</div>
</div>
</aside>
<!-- 主聊天区域 -->
<main class="chat-area">
<!-- 聊天头部 -->
<div class="chat-header card-light">
<div class="chat-info">
<div class="ai-avatar">
<el-icon><User /></el-icon>
</div>
<div class="ai-details">
<h3>UniLife AI助手</h3>
<span class="ai-status">
<span class="status-dot"></span>
在线
</span>
</div>
</div>
<div class="chat-actions">
<el-button text @click="clearCurrentChat">
<el-icon><Delete /></el-icon>
清空对话
</el-button>
</div>
</div>
<!-- 消息列表 -->
<div class="messages-container" ref="messagesContainer">
<!-- 欢迎消息 -->
<div v-if="messages.length === 0" class="welcome-section">
<div class="welcome-avatar">
<el-icon><User /></el-icon>
</div>
<h2>你好我是UniLife AI助手</h2>
<p>我可以帮助你解答学习生活技术等各方面的问题</p>
<!-- 示例问题 -->
<div class="example-questions">
<h4>你可以这样问我</h4>
<div class="questions-grid">
<div
v-for="example in exampleQuestions"
:key="example"
class="example-item"
@click="sendExampleQuestion(example)"
>
{{ example }}
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div class="messages-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ 'user-message': message.role === 'user', 'ai-message': message.role === 'assistant' }"
>
<div class="message-avatar">
<el-avatar v-if="message.role === 'user'" :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<div v-else class="ai-avatar-small">
<el-icon><User /></el-icon>
</div>
</div>
<div class="message-content">
<div class="message-bubble">
<div v-if="message.role === 'user'" class="message-text">
{{ message.content }}
</div>
<div v-else class="ai-response">
<MdPreview
v-if="!message.typing"
:model-value="message.content"
preview-theme="default"
code-theme="atom"
class="ai-markdown"
/>
<div v-else class="typing-indicator">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
<div class="message-time">{{ message.timestamp }}</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area card-light">
<div class="input-container">
<el-input
v-model="inputMessage"
type="textarea"
:rows="1"
resize="none"
placeholder="请输入您的问题..."
class="message-input"
@keydown="handleKeyDown"
/>
<el-button
type="primary"
class="send-btn"
:loading="isLoading"
:disabled="!inputMessage.trim()"
@click="sendMessage"
>
<el-icon v-if="!isLoading"><Promotion /></el-icon>
{{ isLoading ? '发送中...' : '发送' }}
</el-button>
</div>
<div class="input-footer">
<span class="input-tip"> Ctrl + Enter 发送消息</span>
<span class="token-count">{{ inputMessage.length }}/2000</span>
</div>
</div>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Setting,
Plus,
ChatDotRound,
User,
Delete,
Promotion
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import {
sendMessage as sendMessageAPI,
getChatHistory as getChatHistoryAPI,
getChatMessages as getChatMessagesAPI,
createChatSession as createChatSessionAPI,
deleteChatSession as deleteChatSessionAPI,
clearSessionMessages as clearSessionMessagesAPI,
type ChatMessage,
type ChatSession
} from '@/api/ai'
import type { ApiResponse } from '@/types'
const router = useRouter()
const userStore = useUserStore()
//
const inputMessage = ref('')
const isLoading = ref(false)
const currentChatId = ref<string | null>(null)
const messagesContainer = ref()
//
const chatHistory = ref<any[]>([])
const messages = ref<any[]>([])
//
const exampleQuestions = [
'如何有效地学习编程?',
'大学生活有什么建议?',
'如何准备考研?',
'写一个Python快速排序算法',
'推荐一些提高效率的工具',
'如何保持学习动力?'
]
//
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
} else if (command === 'profile') {
router.push('/profile')
}
}
const startNewChat = () => {
currentChatId.value = null
messages.value = []
}
const selectChat = async (chatId: string) => {
currentChatId.value = chatId
//
await loadChatMessages(chatId)
}
const sendExampleQuestion = (question: string) => {
inputMessage.value = question
sendMessage()
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
const userMessage = {
id: Date.now().toString(),
role: 'user',
content: inputMessage.value.trim(),
timestamp: new Date().toLocaleTimeString()
}
messages.value.push(userMessage)
const messageContent = inputMessage.value.trim()
inputMessage.value = ''
//
await nextTick()
scrollToBottom()
try {
isLoading.value = true
// AI typing
const aiMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '',
typing: true,
timestamp: new Date().toLocaleTimeString()
}
messages.value.push(aiMessage)
await nextTick()
scrollToBottom()
// AI
const response = await callAIAPI(messageContent)
// typing
aiMessage.typing = false
aiMessage.content = response
await nextTick()
scrollToBottom()
} catch (error: any) {
ElMessage.error('发送失败:' + (error.message || '网络错误'))
messages.value.pop() // AI
} finally {
isLoading.value = false
}
}
const callAIAPI = async (message: string): Promise<string> => {
// AI API
return `这是对"${message}"的AI回复示例。\n\n**功能说明:**\n- 支持Markdown格式\n- 支持代码高亮\n- 支持数学公式\n\n\`\`\`javascript\nconsole.log('Hello, UniLife!');\n\`\`\`\n\n希望这个回复对您有帮助`
}
const clearCurrentChat = () => {
messages.value = []
ElMessage.success('对话已清空')
}
const loadChatMessages = async (chatId: string) => {
// AI API
messages.value = []
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault()
sendMessage()
}
}
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
onMounted(() => {
console.log('AI助手页面加载完成')
//
loadChatHistory()
})
const loadChatHistory = async () => {
// AI API
chatHistory.value = []
}
</script>
<style scoped>
.ai-assistant-container {
min-height: 100vh;
background: var(--gradient-bg);
}
/* 导航栏样式 */
.ai-navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover,
.nav-item.active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.ai-main {
padding: 24px;
height: calc(100vh - 88px);
}
.ai-content {
max-width: 1400px;
margin: 0 auto;
height: 100%;
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
}
/* 侧边栏样式 */
.chat-sidebar {
padding: 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--gray-200);
}
.sidebar-header h3 {
color: var(--gray-800);
font-size: 18px;
font-weight: 700;
margin: 0;
}
.new-chat-btn {
font-size: 12px;
padding: 8px 12px;
border-radius: 8px;
}
.chat-history {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-item {
padding: 12px 16px;
border-radius: 12px;
cursor: pointer;
transition: var(--transition-base);
border: 1px solid transparent;
}
.chat-item:hover {
background: var(--gray-50);
border-color: var(--gray-200);
}
.chat-item.active {
background: var(--primary-50);
border-color: var(--primary-200);
}
.chat-title {
font-weight: 600;
color: var(--gray-800);
font-size: 14px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-time {
color: var(--gray-500);
font-size: 12px;
}
.empty-history {
text-align: center;
padding: 40px 20px;
color: var(--gray-500);
}
.empty-icon {
font-size: 32px;
margin-bottom: 12px;
color: var(--gray-400);
}
/* 主聊天区域 */
.chat-area {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-header {
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--gray-200);
}
.chat-info {
display: flex;
align-items: center;
gap: 16px;
}
.ai-avatar {
width: 48px;
height: 48px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.ai-details h3 {
color: var(--gray-800);
font-size: 18px;
font-weight: 700;
margin: 0;
}
.ai-status {
display: flex;
align-items: center;
gap: 6px;
color: var(--gray-600);
font-size: 14px;
}
.status-dot {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
}
/* 消息区域 */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.welcome-section {
text-align: center;
padding: 60px 20px;
}
.welcome-avatar {
width: 80px;
height: 80px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 40px;
margin: 0 auto 24px;
}
.welcome-section h2 {
color: var(--gray-800);
font-size: 28px;
font-weight: 700;
margin-bottom: 12px;
}
.welcome-section p {
color: var(--gray-600);
font-size: 16px;
margin-bottom: 40px;
}
.example-questions h4 {
color: var(--gray-700);
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.questions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
max-width: 600px;
margin: 0 auto;
}
.example-item {
padding: 12px 16px;
background: white;
border: 2px solid var(--gray-200);
border-radius: 12px;
cursor: pointer;
transition: var(--transition-base);
font-size: 14px;
color: var(--gray-700);
}
.example-item:hover {
border-color: var(--primary-300);
background: var(--primary-50);
transform: translateY(-2px);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.message-item {
display: flex;
gap: 12px;
}
.message-item.user-message {
flex-direction: row-reverse;
}
.message-avatar {
flex-shrink: 0;
}
.ai-avatar-small {
width: 36px;
height: 36px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.message-content {
flex: 1;
max-width: 70%;
}
.user-message .message-content {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-bubble {
padding: 16px 20px;
border-radius: 16px;
margin-bottom: 4px;
}
.user-message .message-bubble {
background: var(--gradient-primary);
color: white;
border-bottom-right-radius: 4px;
}
.ai-message .message-bubble {
background: white;
border: 1px solid var(--gray-200);
border-bottom-left-radius: 4px;
}
.message-text {
line-height: 1.6;
word-wrap: break-word;
}
.ai-markdown {
font-size: 14px !important;
line-height: 1.6 !important;
}
.message-time {
font-size: 12px;
color: var(--gray-500);
}
.user-message .message-time {
text-align: right;
}
/* 打字指示器 */
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 0;
}
.typing-dot {
width: 8px;
height: 8px;
background: var(--gray-400);
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes typing {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 输入区域 */
.input-area {
padding: 20px 24px;
border-top: 1px solid var(--gray-200);
}
.input-container {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message-input {
flex: 1;
}
.send-btn {
padding: 12px 20px;
border-radius: 12px;
font-weight: 600;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: var(--gray-500);
}
.token-count {
color: var(--gray-400);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.ai-content {
grid-template-columns: 1fr;
}
.chat-sidebar {
display: none;
}
}
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.ai-main {
padding: 16px;
}
.messages-container {
padding: 16px;
}
.message-content {
max-width: 85%;
}
}
</style>

@ -346,7 +346,7 @@ const steps = ref([
}
.nav-container {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
display: flex;
@ -426,7 +426,7 @@ const steps = ref([
}
.hero-content {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
@ -663,7 +663,7 @@ const steps = ref([
}
.section-container {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
}
@ -806,7 +806,7 @@ const steps = ref([
}
.footer-container {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 2fr;

File diff suppressed because it is too large Load Diff

@ -1,46 +1,7 @@
<template>
<div class="forum-container">
<!-- 顶部导航栏 -->
<nav class="forum-navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item active">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="forum-main">
@ -338,7 +299,6 @@ import {
View,
ChatDotRound,
Star,
Setting,
School,
EditPen,
InfoFilled
@ -348,6 +308,7 @@ import { getPosts, getCategories, createPost, likePost } from '@/api/forum'
import type { Post, Category, ApiResponse } from '@/types'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import TopNavbar from '@/components/TopNavbar.vue'
const router = useRouter()
const userStore = useUserStore()
@ -493,15 +454,6 @@ const handleCreatePost = async () => {
}
}
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
router.push('/login')
} else if (command === 'profile') {
router.push('/profile')
}
}
//
const loadPosts = async () => {
try {
@ -639,94 +591,13 @@ onMounted(() => {
background: var(--gradient-bg);
}
/* 导航栏样式 */
.forum-navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover,
.nav-item.active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.forum-main {
padding: 32px 24px;
}
.forum-content {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 280px 1fr;
@ -970,10 +841,6 @@ onMounted(() => {
}
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.forum-main {
padding: 16px;
}

@ -1,35 +1,7 @@
<template>
<div class="post-detail-container">
<!-- 顶部导航栏 -->
<nav class="forum-navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
</div>
</div>
</nav>
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="post-detail-main">
@ -231,6 +203,7 @@ import { getPostDetail, likePost as likePostAPI, getComments, createComment as c
import type { Post, ApiResponse } from '@/types'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import TopNavbar from '@/components/TopNavbar.vue'
const router = useRouter()
const route = useRoute()
@ -408,86 +381,6 @@ onMounted(async () => {
background: var(--gradient-bg);
}
/* 导航栏样式 - 复用论坛页面的样式 */
.forum-navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.post-detail-main {
padding: 32px 24px;
@ -893,10 +786,6 @@ onMounted(async () => {
/* 响应式设计 */
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.post-detail-main {
padding: 16px;
}

@ -1,49 +1,10 @@
<template>
<div class="profile-container">
<!-- 顶部导航栏 -->
<nav class="navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="profile-main">
<div class="profile-main" v-loading="loading" element-loading-text="正在加载用户信息...">
<div class="profile-content">
<!-- 个人信息卡片 -->
<div class="profile-header card-light">
@ -58,11 +19,11 @@
</div>
<div class="user-basic-info">
<h1 class="user-name">{{ userProfile.nickname }}</h1>
<h1 class="user-name">{{ userProfile.nickname || '加载中...' }}</h1>
<p class="user-title">{{ userProfile.department }} · {{ userProfile.major }}</p>
<p class="user-grade">{{ userProfile.grade }}</p>
<div class="user-stats">
<div class="user-stats" v-loading="statsLoading" element-loading-text="加载统计中...">
<div class="stat-item">
<span class="stat-number">{{ userStats.postsCount }}</span>
<span class="stat-label">发帖</span>
@ -103,15 +64,15 @@
<div class="info-grid">
<div class="info-item">
<span class="info-label">用户名</span>
<span class="info-value">{{ userProfile.username }}</span>
<span class="info-value">{{ userProfile.username || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">邮箱</span>
<span class="info-value">{{ userProfile.email }}</span>
<span class="info-value">{{ userProfile.email || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">学号</span>
<span class="info-value">{{ userProfile.studentId }}</span>
<span class="info-value">{{ userProfile.studentId || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">性别</span>
@ -119,11 +80,11 @@
</div>
<div class="info-item">
<span class="info-label">学院</span>
<span class="info-value">{{ userProfile.department }}</span>
<span class="info-value">{{ userProfile.department || '未设置' }}</span>
</div>
<div class="info-item">
<span class="info-label">专业</span>
<span class="info-value">{{ userProfile.major }}</span>
<span class="info-value">{{ userProfile.major || '未设置' }}</span>
</div>
</div>
@ -139,46 +100,60 @@
<div class="activity-tabs">
<el-tabs v-model="activeTab">
<el-tab-pane label="我的帖子" name="posts">
<div class="posts-list">
<div
v-for="post in recentPosts"
:key="post.id"
class="post-item"
@click="viewPost(post.id)"
>
<div class="post-info">
<h4 class="post-title">{{ post.title }}</h4>
<p class="post-summary">{{ post.content }}</p>
<div class="post-meta">
<span class="post-time">{{ post.createTime }}</span>
<div class="post-stats">
<span>{{ post.viewsCount }} 浏览</span>
<span>{{ post.likesCount }} 点赞</span>
<span>{{ post.commentsCount }} 评论</span>
<div class="activity-content" v-loading="postsLoading" element-loading-text="加载帖子中...">
<div v-if="recentPosts.length > 0" class="posts-list">
<div
v-for="post in recentPosts"
:key="post.id"
class="post-item"
@click="viewPost(post.id)"
>
<div class="post-info">
<h4 class="post-title">{{ post.title }}</h4>
<p class="post-summary">{{ getPostSummary(post.content) }}</p>
<div class="post-meta">
<span class="post-time">{{ post.createdAt }}</span>
<div class="post-stats">
<span>{{ post.viewCount }} 浏览</span>
<span>{{ post.likeCount }} 点赞</span>
<span>{{ post.commentCount }} 评论</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="!postsLoading" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>还没有发布任何帖子</p>
<el-button type="primary" @click="$router.push('/forum')"></el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="我的资源" name="resources">
<div class="resources-list">
<div
v-for="resource in recentResources"
:key="resource.id"
class="resource-item"
>
<el-icon class="resource-icon"><Document /></el-icon>
<div class="resource-info">
<h4 class="resource-title">{{ resource.title }}</h4>
<p class="resource-desc">{{ resource.description }}</p>
<div class="resource-meta">
<span class="resource-time">{{ resource.createdAt }}</span>
<span class="resource-downloads">{{ resource.downloadCount }} 下载</span>
<div class="activity-content" v-loading="resourcesLoading" element-loading-text="加载资源中...">
<div v-if="recentResources.length > 0" class="resources-list">
<div
v-for="resource in recentResources"
:key="resource.id"
class="resource-item"
>
<el-icon class="resource-icon"><Document /></el-icon>
<div class="resource-info">
<h4 class="resource-title">{{ resource.title }}</h4>
<p class="resource-desc">{{ resource.description }}</p>
<div class="resource-meta">
<span class="resource-time">{{ resource.createdAt }}</span>
<span class="resource-downloads">{{ resource.downloadCount }} 下载</span>
</div>
</div>
</div>
</div>
<div v-else-if="!resourcesLoading" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>还没有上传任何资源</p>
<el-button type="primary" @click="$router.push('/resources')"></el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
@ -331,6 +306,18 @@ import {
Plus
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import {
getUserInfo,
getUserStats,
getUserRecentPosts,
updateUserProfile,
changePassword,
uploadAvatar,
sendEmailCode
} from '@/api/user'
import { getMyResources } from '@/api/resources'
import type { ApiResponse } from '@/types'
import TopNavbar from '@/components/TopNavbar.vue'
const router = useRouter()
const userStore = useUserStore()
@ -341,74 +328,44 @@ const showChangePassword = ref(false)
const showAvatarUpload = ref(false)
const activeTab = ref('posts')
const codeCountdown = ref(0)
const loading = ref(false)
const statsLoading = ref(false)
const postsLoading = ref(false)
const resourcesLoading = ref(false)
//
// - 使API
const userProfile = ref({
id: 12345,
username: 'student123',
email: 'student@school.edu',
nickname: '测试用户',
id: 0,
username: '',
email: '',
nickname: '',
avatar: '',
bio: '这是一个热爱学习的大学生,喜欢分享知识和经验。',
gender: 1,
studentId: '20220101001',
department: '计算机学院',
major: '软件工程',
grade: '2023级',
points: 1250,
bio: '',
gender: 0,
studentId: '',
department: '',
major: '',
grade: '',
points: 0,
role: 0,
isVerified: 1
isVerified: 0
})
//
// - 使API
const userStats = ref({
postsCount: 25,
commentsCount: 150,
resourcesCount: 10,
likesReceived: 300,
coursesCount: 8,
schedulesCount: 15
postsCount: 0,
commentsCount: 0,
resourcesCount: 0,
likesReceived: 0,
coursesCount: 0,
schedulesCount: 0
})
//
const recentPosts = ref([
{
id: 1,
title: '分享一些高效学习方法',
content: '经过一学期的摸索,总结了一些比较有效的学习方法...',
createTime: '2024-01-15 10:30',
viewsCount: 234,
likesCount: 89,
commentsCount: 23
},
{
id: 2,
title: '大学生活时间管理心得',
content: '作为一名大二学生,想和大家分享一些时间管理的经验...',
createTime: '2024-01-12 16:20',
viewsCount: 156,
likesCount: 45,
commentsCount: 12
}
])
//
const recentResources = ref([
{
id: 1,
title: '数据结构课件整理',
description: '完整的数据结构课程课件,包含所有章节',
createdAt: '2024-01-10 14:30',
downloadCount: 89
},
{
id: 2,
title: '高等数学期末复习资料',
description: '精心整理的高数复习重点和练习题',
createdAt: '2024-01-08 09:15',
downloadCount: 156
}
])
// - 使API
const recentPosts = ref<any[]>([])
// - 使API
const recentResources = ref<any[]>([])
//
const editForm = reactive({
@ -427,52 +384,239 @@ const passwordForm = reactive({
confirmPassword: ''
})
//
const loadUserProfile = async () => {
try {
loading.value = true
const response = await getUserInfo() as any as ApiResponse<typeof userProfile.value>
if (response.code === 200) {
userProfile.value = response.data
console.log('用户信息加载成功:', userProfile.value)
//
Object.assign(editForm, {
nickname: userProfile.value.nickname,
department: userProfile.value.department,
major: userProfile.value.major,
grade: userProfile.value.grade,
gender: userProfile.value.gender,
bio: userProfile.value.bio
})
} else {
console.error('获取用户信息失败:', response.message)
ElMessage.error(response.message || '获取用户信息失败')
}
} catch (error) {
console.error('获取用户信息失败:', error)
ElMessage.error('获取用户信息失败')
} finally {
loading.value = false
}
}
//
const loadUserStats = async () => {
try {
statsLoading.value = true
const response = await getUserStats() as any as ApiResponse<{
totalPosts: number
totalLikes: number
totalComments: number
totalViews: number
}>
if (response.code === 200) {
//
userStats.value = {
postsCount: response.data.totalPosts || 0,
commentsCount: response.data.totalComments || 0,
resourcesCount: 0, // 使
likesReceived: response.data.totalLikes || 0,
coursesCount: 0, // 使
schedulesCount: 0 // 使
}
console.log('用户统计数据加载成功:', userStats.value)
} else {
console.error('获取用户统计失败:', response.message)
}
} catch (error) {
console.error('获取用户统计失败:', error)
} finally {
statsLoading.value = false
}
}
//
const loadRecentPosts = async () => {
try {
postsLoading.value = true
const response = await getUserRecentPosts(5) as any as ApiResponse<{
posts: any[]
totalCount: number
}>
if (response.code === 200) {
recentPosts.value = response.data.posts || []
console.log('最近帖子加载成功:', recentPosts.value)
} else {
console.error('获取最近帖子失败:', response.message)
}
} catch (error) {
console.error('获取最近帖子失败:', error)
} finally {
postsLoading.value = false
}
}
//
const loadRecentResources = async () => {
try {
resourcesLoading.value = true
const response = await getMyResources({ page: 1, size: 5 }) as any as ApiResponse<{
total: number
list: any[]
pages: number
}>
if (response.code === 200) {
recentResources.value = response.data.list
console.log('最近资源加载成功:', recentResources.value)
} else {
console.error('获取最近资源失败:', response.message)
}
} catch (error) {
console.error('获取最近资源失败:', error)
} finally {
resourcesLoading.value = false
}
}
//
const getGenderText = (gender: number) => {
const genderMap = { 0: '保密', 1: '男', 2: '女' }
return genderMap[gender as keyof typeof genderMap] || '保密'
}
const getPostSummary = (content: string) => {
if (!content) return '无内容'
// markdown
let summary = content
.replace(/#{1,6}\s+/g, '') //
.replace(/\*\*(.*?)\*\*/g, '$1') //
.replace(/\*(.*?)\*/g, '$1') //
.replace(/`(.*?)`/g, '$1') //
.replace(/```[\s\S]*?```/g, '') //
.replace(/!\[.*?\]\(.*?\)/g, '') //
.replace(/\[.*?\]\(.*?\)/g, '') //
.replace(/\n+/g, ' ') //
.trim()
//
return summary.length > 120 ? summary.substring(0, 120) + '...' : summary
}
const viewPost = (postId: number) => {
router.push(`/forum/post/${postId}`)
}
const handleUpdateProfile = () => {
//
Object.assign(userProfile.value, editForm)
showEditProfile.value = false
ElMessage.success('资料更新成功!')
const handleUpdateProfile = async () => {
try {
const response = await updateUserProfile({
username: editForm.nickname, // APIusername
bio: editForm.bio,
gender: editForm.gender,
department: editForm.department,
major: editForm.major,
grade: editForm.grade
}) as any as ApiResponse<null>
if (response.code === 200) {
//
Object.assign(userProfile.value, editForm)
showEditProfile.value = false
ElMessage.success('资料更新成功!')
//
await loadUserProfile()
} else {
ElMessage.error(response.message || '更新失败')
}
} catch (error) {
console.error('更新用户资料失败:', error)
ElMessage.error('更新失败')
}
}
const sendVerificationCode = () => {
//
codeCountdown.value = 60
const timer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) {
clearInterval(timer)
const sendVerificationCode = async () => {
try {
const response = await sendEmailCode(userProfile.value.email) as any as ApiResponse<null>
if (response.code === 200) {
//
codeCountdown.value = 60
const timer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
ElMessage.success('验证码已发送到您的邮箱')
} else {
ElMessage.error(response.message || '发送验证码失败')
}
}, 1000)
ElMessage.success('验证码已发送到您的邮箱')
} catch (error) {
console.error('发送验证码失败:', error)
ElMessage.error('发送验证码失败')
}
}
const handleChangePassword = () => {
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
showChangePassword.value = false
ElMessage.success('密码修改成功!')
try {
const response = await changePassword({
code: passwordForm.code,
newPassword: passwordForm.newPassword
}) as any as ApiResponse<null>
if (response.code === 200) {
showChangePassword.value = false
//
passwordForm.code = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
ElMessage.success('密码修改成功!')
} else {
ElMessage.error(response.message || '密码修改失败')
}
} catch (error) {
console.error('修改密码失败:', error)
ElMessage.error('修改密码失败')
}
}
const handleAvatarChange = (file: any) => {
console.log('选择的头像文件:', file)
}
const handleUploadAvatar = () => {
showAvatarUpload.value = false
ElMessage.success('头像上传成功!')
// TODO:
}
const handleUploadAvatar = async () => {
try {
// TODO:
showAvatarUpload.value = false
ElMessage.success('头像上传成功!')
//
await loadUserProfile()
} catch (error) {
console.error('上传头像失败:', error)
ElMessage.error('头像上传失败')
}
}
const handleCommand = (command: string) => {
@ -484,18 +628,16 @@ const handleCommand = (command: string) => {
}
}
onMounted(() => {
//
Object.assign(editForm, {
nickname: userProfile.value.nickname,
department: userProfile.value.department,
major: userProfile.value.major,
grade: userProfile.value.grade,
gender: userProfile.value.gender,
bio: userProfile.value.bio
})
onMounted(async () => {
console.log('个人资料页面加载完成,开始加载数据...')
console.log('个人资料页面加载完成')
//
await Promise.all([
loadUserProfile(),
loadUserStats(),
loadRecentPosts(),
loadRecentResources()
])
})
</script>
@ -505,86 +647,6 @@ onMounted(() => {
background: var(--gradient-bg);
}
/* 导航栏样式 - 复用之前的样式 */
.navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.profile-main {
padding: 32px 24px;
@ -741,6 +803,34 @@ onMounted(() => {
padding: 24px;
}
.activity-tabs {
height: 100%;
}
.activity-content {
height: 400px;
overflow-y: auto;
padding-right: 8px;
}
.activity-content::-webkit-scrollbar {
width: 6px;
}
.activity-content::-webkit-scrollbar-track {
background: var(--gray-100);
border-radius: 3px;
}
.activity-content::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 3px;
}
.activity-content::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
.posts-list,
.resources-list {
display: flex;
@ -872,47 +962,84 @@ onMounted(() => {
/* 响应式设计 */
@media (max-width: 1024px) {
.profile-details {
grid-template-columns: 1fr;
.profile-content {
max-width: 800px;
}
.profile-header {
flex-direction: column;
gap: 24px;
align-items: center;
text-align: center;
}
.avatar-section {
flex-direction: column;
text-align: center;
gap: 20px;
}
.user-stats {
justify-content: center;
}
.profile-details {
flex-direction: column;
}
.info-section,
.activity-section {
width: 100%;
}
}
@media (max-width: 768px) {
.nav-menu {
display: none;
.profile-main {
padding: 24px 16px;
}
.profile-main {
padding: 16px;
.user-stats {
flex-wrap: wrap;
gap: 16px;
}
.profile-header {
padding: 24px 16px;
.stat-item {
min-width: calc(50% - 8px);
}
.info-grid {
grid-template-columns: 1fr;
}
.user-stats {
justify-content: center;
}
.profile-actions {
width: 100%;
justify-content: center;
flex-direction: column;
gap: 12px;
}
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--gray-500);
}
.empty-icon {
font-size: 48px;
color: var(--gray-400);
margin-bottom: 16px;
}
.empty-state p {
font-size: 16px;
margin-bottom: 20px;
color: var(--gray-600);
}
.empty-state .el-button {
border-radius: 8px;
font-weight: 600;
}
</style>

@ -1,46 +1,7 @@
<template>
<div class="resource-detail-container">
<!-- 顶部导航栏 -->
<nav class="navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item active">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="resource-detail-main">
@ -198,6 +159,7 @@ import {
type Resource as BaseResource
} from '@/api/resources'
import type { ApiResponse } from '@/types'
import TopNavbar from '@/components/TopNavbar.vue'
// ResourceUI
interface ExtendedResource extends BaseResource {
@ -378,87 +340,6 @@ onMounted(() => {
background: var(--gradient-bg);
}
/* 导航栏样式 - 复用之前的样式 */
.navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover,
.nav-item.active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.resource-detail-main {
padding: 32px 24px;
@ -631,10 +512,6 @@ onMounted(() => {
/* 响应式设计 */
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.resource-detail-main {
padding: 16px;
}

@ -1,46 +1,7 @@
<template>
<div class="resources-container">
<!-- 顶部导航栏 -->
<nav class="navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item active">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="resources-main">
@ -349,6 +310,7 @@ import {
} from '@/api/resources'
import { getCategories } from '@/api/forum'
import type { ApiResponse, Category } from '@/types'
import TopNavbar from '@/components/TopNavbar.vue'
// ResourceUI
interface ExtendedResource extends BaseResource {
@ -770,94 +732,13 @@ const loadTotalResourcesCount = async () => {
background: var(--gradient-bg);
}
/* 导航栏样式 - 复用之前的样式 */
.navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover,
.nav-item.active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.resources-main {
padding: 32px 24px;
}
.resources-content {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 280px 1fr;

File diff suppressed because it is too large Load Diff

@ -1,46 +1,7 @@
<template>
<div class="task-container">
<!-- 顶部导航栏 -->
<nav class="navbar glass-light">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/" class="brand-link">
<div class="logo-circle">
<i class="el-icon-star-filled"></i>
</div>
<span class="brand-name gradient-text">UniLife</span>
</router-link>
</div>
<div class="nav-menu">
<router-link to="/forum" class="nav-item">论坛</router-link>
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item">课程表</router-link>
<router-link to="/tasks" class="nav-item active">日程管理</router-link>
<router-link to="/ai-assistant" class="nav-item">AI助手</router-link>
</div>
<div class="nav-actions">
<div class="user-info">
<el-avatar :size="36" :src="userStore.user?.avatar">
{{ userStore.user?.nickname?.charAt(0) }}
</el-avatar>
<span class="username">{{ userStore.user?.nickname }}</span>
</div>
<el-dropdown @command="handleCommand">
<el-button circle>
<el-icon><Setting /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</nav>
<!-- 使用通用顶部导航栏组件 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="task-main">
@ -342,6 +303,7 @@ import {
type Schedule
} from '@/api/schedule'
import type { ApiResponse } from '@/types'
import TopNavbar from '@/components/TopNavbar.vue'
const router = useRouter()
const userStore = useUserStore()
@ -734,94 +696,13 @@ onMounted(async () => {
background: var(--gradient-bg);
}
/* 导航栏样式 */
.navbar {
position: sticky;
top: 0;
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid var(--gray-200);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.logo-circle {
width: 40px;
height: 40px;
background: var(--gradient-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: var(--shadow-light);
}
.brand-name {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
gap: 32px;
}
.nav-item {
text-decoration: none;
color: var(--gray-600);
font-weight: 600;
padding: 8px 16px;
border-radius: 12px;
transition: var(--transition-base);
}
.nav-item:hover,
.nav-item.active {
color: var(--primary-600);
background: var(--primary-50);
}
.nav-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-weight: 600;
color: var(--gray-700);
}
/* 主要内容区域 */
.task-main {
padding: 32px 24px;
}
.task-content {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
@ -870,7 +751,7 @@ onMounted(async () => {
/* 今日日程视图 */
.today-view {
margin-top: 24px;
width: 100%;
}
.today-schedules {
@ -1004,7 +885,7 @@ onMounted(async () => {
/* 列表视图 */
.list-view {
margin-top: 24px;
width: 100%;
}
.list-container {
@ -1158,10 +1039,6 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.nav-menu {
display: none;
}
.task-main {
padding: 16px;
}

@ -24,8 +24,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/users/register",
"/users/code",
"/users/login/code",
"/ai/**",
// 静态资源访问
"/api/files/**",

@ -1,27 +1,87 @@
package com.unilife.controller;
import com.unilife.common.result.Result;
import com.unilife.model.dto.AiCreateSessionDTO;
import com.unilife.model.dto.AiSendMessageDTO;
import com.unilife.model.dto.AiUpdateSessionDTO;
import com.unilife.model.vo.AiCreateSessionVO;
import com.unilife.model.vo.AiMessageHistoryVO;
import com.unilife.model.vo.AiSessionListVO;
import com.unilife.service.AiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
@RestController
@Slf4j
@RequestMapping("/ai")
@Tag(name = "AI对话")
@AllArgsConstructor
@Tag(name = "AI辅助学习")
@RequiredArgsConstructor
public class AiController {
private final ChatClient chatClient;
private final AiService aiService;
@RequestMapping(value = "/chat",produces = "text/html;charset=UTF-8")
public Flux<String>chat(String prompt,String chatId){
return chatClient.prompt(prompt)
.stream()
.content();
@Operation(summary = "发送消息给AI")
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> sendMessage(@RequestBody AiSendMessageDTO sendMessageDTO) {
log.info("发送消息给AI: {}", sendMessageDTO.getMessage());
return aiService.sendMessage(sendMessageDTO);
}
@Operation(summary = "获取聊天会话列表")
@GetMapping("/sessions")
public Result<AiSessionListVO> getSessionList(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页大小") @RequestParam(defaultValue = "20") Integer size) {
log.info("获取会话列表,页码: {}, 每页大小: {}", page, size);
return aiService.getSessionList(page, size);
}
@Operation(summary = "获取会话消息历史")
@GetMapping("/sessions/{sessionId}/messages")
public Result<AiMessageHistoryVO> getSessionMessages(
@Parameter(description = "会话ID") @PathVariable String sessionId,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页大小") @RequestParam(defaultValue = "50") Integer size) {
log.info("获取会话消息历史会话ID: {}, 页码: {}, 每页大小: {}", sessionId, page, size);
return aiService.getSessionMessages(sessionId, page, size);
}
@Operation(summary = "创建聊天会话")
@PostMapping("/sessions")
public Result<AiCreateSessionVO> createSession(@RequestBody AiCreateSessionDTO createSessionDTO) {
log.info("创建聊天会话: {}", createSessionDTO.getSessionId());
return aiService.createSession(createSessionDTO);
}
@Operation(summary = "更新会话标题")
@PutMapping("/sessions/{sessionId}")
public Result<Void> updateSessionTitle(
@Parameter(description = "会话ID") @PathVariable String sessionId,
@RequestBody AiUpdateSessionDTO updateSessionDTO) {
log.info("更新会话标题会话ID: {}, 新标题: {}", sessionId, updateSessionDTO.getTitle());
return aiService.updateSessionTitle(sessionId, updateSessionDTO);
}
@Operation(summary = "清空会话消息")
@DeleteMapping("/sessions/{sessionId}/messages")
public Result<Void> clearSessionMessages(@Parameter(description = "会话ID") @PathVariable String sessionId) {
log.info("清空会话消息会话ID: {}", sessionId);
return aiService.clearSessionMessages(sessionId);
}
@Operation(summary = "删除会话")
@DeleteMapping("/sessions/{sessionId}")
public Result<Void> deleteSession(@Parameter(description = "会话ID") @PathVariable String sessionId) {
log.info("删除会话会话ID: {}", sessionId);
return aiService.deleteSession(sessionId);
}
}

@ -0,0 +1,20 @@
package com.unilife.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiCreateSessionDTO {
/**
* ID
*/
private String sessionId;
/**
*
*/
private String title;
}

@ -0,0 +1,37 @@
package com.unilife.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiSendMessageDTO {
/**
*
*/
private String message;
/**
* ID
*/
private String sessionId;
/**
*
*/
private List<ConversationMessage> conversationHistory;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ConversationMessage {
private String id;
private String role;
private String content;
private String timestamp;
}
}

@ -0,0 +1,15 @@
package com.unilife.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiUpdateSessionDTO {
/**
*
*/
private String title;
}

@ -0,0 +1,37 @@
package com.unilife.model.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiChatMessage {
/**
* ID
*/
private String id;
/**
* ID
*/
private String sessionId;
/**
* (user, assistant, system)
*/
private String role;
/**
*
*/
private String content;
/**
*
*/
private LocalDateTime createdAt;
}

@ -0,0 +1,37 @@
package com.unilife.model.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiChatSession {
/**
* ID
*/
private String id;
/**
* ID
*/
private Long userId;
/**
*
*/
private String title;
/**
*
*/
private LocalDateTime createdAt;
/**
*
*/
private LocalDateTime updatedAt;
}

@ -0,0 +1,20 @@
package com.unilife.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiCreateSessionVO {
/**
* ID
*/
private String sessionId;
/**
*
*/
private String title;
}

@ -0,0 +1,27 @@
package com.unilife.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiMessageHistoryVO {
/**
*
*/
private List<AiMessageVO> messages;
/**
*
*/
private Long total;
/**
*
*/
private AiSessionVO sessionInfo;
}

@ -0,0 +1,30 @@
package com.unilife.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiMessageVO {
/**
* ID
*/
private String id;
/**
*
*/
private String role;
/**
*
*/
private String content;
/**
*
*/
private String timestamp;
}

@ -0,0 +1,22 @@
package com.unilife.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiSessionListVO {
/**
*
*/
private List<AiSessionVO> sessions;
/**
*
*/
private Long total;
}

@ -0,0 +1,40 @@
package com.unilife.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AiSessionVO {
/**
* ID
*/
private String id;
/**
*
*/
private String title;
/**
*
*/
private String createdAt;
/**
*
*/
private String updatedAt;
/**
*
*/
private String lastMessageTime;
/**
*
*/
private Integer messageCount;
}

@ -0,0 +1,69 @@
package com.unilife.service;
import com.unilife.common.result.Result;
import com.unilife.model.dto.AiCreateSessionDTO;
import com.unilife.model.dto.AiSendMessageDTO;
import com.unilife.model.dto.AiUpdateSessionDTO;
import com.unilife.model.vo.AiCreateSessionVO;
import com.unilife.model.vo.AiMessageHistoryVO;
import com.unilife.model.vo.AiSessionListVO;
import reactor.core.publisher.Flux;
/**
* AI
*/
public interface AiService {
/**
* AI
* @param sendMessageDTO DTO
* @return
*/
Flux<String> sendMessage(AiSendMessageDTO sendMessageDTO);
/**
*
* @param page
* @param size
* @return
*/
Result<AiSessionListVO> getSessionList(Integer page, Integer size);
/**
*
* @param sessionId ID
* @param page
* @param size
* @return
*/
Result<AiMessageHistoryVO> getSessionMessages(String sessionId, Integer page, Integer size);
/**
*
* @param createSessionDTO DTO
* @return
*/
Result<AiCreateSessionVO> createSession(AiCreateSessionDTO createSessionDTO);
/**
*
* @param sessionId ID
* @param updateSessionDTO DTO
* @return
*/
Result<Void> updateSessionTitle(String sessionId, AiUpdateSessionDTO updateSessionDTO);
/**
*
* @param sessionId ID
* @return
*/
Result<Void> clearSessionMessages(String sessionId);
/**
*
* @param sessionId ID
* @return
*/
Result<Void> deleteSession(String sessionId);
}

@ -0,0 +1,98 @@
package com.unilife.service.impl;
import com.unilife.common.result.Result;
import com.unilife.model.dto.AiCreateSessionDTO;
import com.unilife.model.dto.AiSendMessageDTO;
import com.unilife.model.dto.AiUpdateSessionDTO;
import com.unilife.model.vo.AiCreateSessionVO;
import com.unilife.model.vo.AiMessageHistoryVO;
import com.unilife.model.vo.AiSessionListVO;
import com.unilife.service.AiService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
@Service
@Slf4j
public class AiServiceImpl implements AiService {
@Autowired
private ChatClient chatClient;
@Override
public Flux<String> sendMessage(AiSendMessageDTO sendMessageDTO) {
log.info("发送消息给AI: {}", sendMessageDTO.getMessage());
// 使用ChatClient的流式响应
return chatClient.prompt(sendMessageDTO.getMessage())
.stream()
.content();
}
@Override
public Result<AiSessionListVO> getSessionList(Integer page, Integer size) {
log.info("获取会话列表,页码: {}, 每页大小: {}", page, size);
// TODO: 实现从数据库获取会话列表的逻辑
AiSessionListVO sessionList = new AiSessionListVO();
sessionList.setSessions(new ArrayList<>());
sessionList.setTotal(0L);
return Result.success(sessionList);
}
@Override
public Result<AiMessageHistoryVO> getSessionMessages(String sessionId, Integer page, Integer size) {
log.info("获取会话消息历史会话ID: {}, 页码: {}, 每页大小: {}", sessionId, page, size);
// TODO: 实现从数据库获取消息历史的逻辑
AiMessageHistoryVO messageHistory = new AiMessageHistoryVO();
messageHistory.setMessages(new ArrayList<>());
messageHistory.setTotal(0L);
return Result.success(messageHistory);
}
@Override
public Result<AiCreateSessionVO> createSession(AiCreateSessionDTO createSessionDTO) {
log.info("创建聊天会话: {}", createSessionDTO.getSessionId());
// TODO: 实现在数据库中创建会话的逻辑
AiCreateSessionVO response = new AiCreateSessionVO();
response.setSessionId(createSessionDTO.getSessionId());
response.setTitle(createSessionDTO.getTitle() != null ? createSessionDTO.getTitle() : "新对话");
return Result.success(response);
}
@Override
public Result<Void> updateSessionTitle(String sessionId, AiUpdateSessionDTO updateSessionDTO) {
log.info("更新会话标题会话ID: {}, 新标题: {}", sessionId, updateSessionDTO.getTitle());
// TODO: 实现在数据库中更新会话标题的逻辑
return Result.success();
}
@Override
public Result<Void> clearSessionMessages(String sessionId) {
log.info("清空会话消息会话ID: {}", sessionId);
// TODO: 实现在数据库中清空会话消息的逻辑
return Result.success();
}
@Override
public Result<Void> deleteSession(String sessionId) {
log.info("删除会话会话ID: {}", sessionId);
// TODO: 实现在数据库中删除会话的逻辑
return Result.success();
}
}

@ -60,6 +60,7 @@ mybatis:
logging:
level:
com.unilife: debug
org.springframework.ai: debug
jwt:
secret: qwertyuiopasdfghjklzxcvbnm
expiration: 86400
@ -71,3 +72,4 @@ aliyun:
accessKeySecret: ${ALIYUN_OSS_ACCESS_KEY_SECRET:your-access-key-secret}
bucketName: ${ALIYUN_OSS_BUCKET_NAME:your-bucket-name}
urlPrefix: ${ALIYUN_OSS_URL_PREFIX:https://your-bucket-name.oss-region.aliyuncs.com/}spring.profiles.active=local

@ -400,22 +400,22 @@ INSERT INTO `resources` (`user_id`, `title`, `description`, `file_url`, `file_si
-- 插入课程数据
INSERT INTO `courses` (`user_id`, `name`, `teacher`, `location`, `day_of_week`, `start_time`, `end_time`, `start_week`, `end_week`, `semester`, `color`, `status`) VALUES
-- 数学学院学生的课程
(2, '高等代数', '张教授', '数学学院楼201', 1, '08:00:00', '09:40:00', 1, 16, '2024-2', '#409EFF', 1),
(2, '实变函数', '李老师', '数学学院楼301', 3, '14:00:00', '15:40:00', 1, 16, '2024-2', '#67C23A', 1),
(2, '数学建模', '王教授', '计算中心机房', 5, '19:00:00', '21:00:00', 1, 16, '2024-2', '#E6A23C', 1),
(2, '高等代数', '张教授', '数学学院楼201', 1, '08:00:00', '09:40:00', 1, 16, '2024-2025-2', '#409EFF', 1),
(2, '实变函数', '李老师', '数学学院楼301', 3, '14:00:00', '15:40:00', 1, 16, '2024-2025-2', '#67C23A', 1),
(2, '数学建模', '王教授', '计算中心机房', 5, '19:00:00', '21:00:00', 1, 16, '2024-2025-2', '#E6A23C', 1),
-- 计算机学院学生的课程
(3, '数据结构与算法', '赵教授', '信息学部计算机楼', 2, '10:00:00', '11:40:00', 1, 16, '2024-2', '#409EFF', 1),
(3, '软件工程', '钱老师', '信息学部B楼302', 4, '14:00:00', '15:40:00', 1, 16, '2024-2', '#67C23A', 1),
(3, '算法竞赛训练', 'ACM教练', '信息学部机房', 6, '19:30:00', '21:30:00', 1, 16, '2024-2', '#E6A23C', 1),
(3, '数据结构与算法', '赵教授', '信息学部计算机楼', 2, '10:00:00', '11:40:00', 1, 16, '2024-2025-2', '#409EFF', 1),
(3, '软件工程', '钱老师', '信息学部B楼302', 4, '14:00:00', '15:40:00', 1, 16, '2024-2025-2', '#67C23A', 1),
(3, '算法竞赛训练', 'ACM教练', '信息学部机房', 6, '19:30:00', '21:30:00', 1, 16, '2024-2025-2', '#E6A23C', 1),
-- 法学院学生的课程
(4, '民法学', '孙教授', '法学院模拟法庭', 1, '10:00:00', '11:40:00', 1, 16, '2024-2', '#409EFF', 1),
(4, '法理学', '周老师', '法学院研讨室', 3, '15:50:00', '17:30:00', 1, 16, '2024-2', '#67C23A', 1),
(4, '民法学', '孙教授', '法学院模拟法庭', 1, '10:00:00', '11:40:00', 1, 16, '2024-2025-2', '#409EFF', 1),
(4, '法理学', '周老师', '法学院研讨室', 3, '15:50:00', '17:30:00', 1, 16, '2024-2025-2', '#67C23A', 1),
-- 经管学院学生的课程
(6, '宏观经济学', '吴教授', '经管大楼B201', 1, '08:00:00', '09:40:00', 1, 16, '2024-2', '#409EFF', 1),
(6, '计量经济学', '郑老师', '经管大楼机房', 2, '10:00:00', '11:40:00', 1, 16, '2024-2', '#67C23A', 1);
(6, '宏观经济学', '吴教授', '经管大楼B201', 1, '08:00:00', '09:40:00', 1, 16, '2024-2025-2', '#409EFF', 1),
(6, '计量经济学', '郑老师', '经管大楼机房', 2, '10:00:00', '11:40:00', 1, 16, '2024-2025-2', '#67C23A', 1);
-- 插入日程数据
INSERT INTO `schedules` (`user_id`, `title`, `description`, `start_time`, `end_time`, `location`, `is_all_day`, `reminder`, `color`, `status`) VALUES

Loading…
Cancel
Save