完善论坛,资源

czq
2991692032 1 week ago
parent 0e176c9883
commit 72d0cf16c4

@ -0,0 +1,396 @@
# 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. **用户体验测试**: 测试实际使用场景和响应时间

@ -0,0 +1,82 @@
import api from './index'
import type { ApiResponse } from '@/types'
// AI聊天相关接口
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: string
}
export interface ChatSession {
id: string
title: string
createdAt: string
updatedAt: string
lastMessageTime: string
messageCount: number
}
export interface SendMessageRequest {
message: string
sessionId?: string
conversationHistory?: ChatMessage[]
}
export interface SendMessageResponse {
messageId: string
content: string
sessionId: string
timestamp: string
}
export interface ChatHistoryResponse {
sessions: ChatSession[]
total: number
}
export interface ChatMessagesResponse {
messages: ChatMessage[]
total: number
sessionInfo: ChatSession
}
// 发送消息给AI
export const sendMessage = (data: SendMessageRequest) => {
return api.post<ApiResponse<SendMessageResponse>>('/ai/chat', data)
}
// 获取聊天历史列表
export const getChatHistory = (page = 1, size = 20) => {
return api.get<ApiResponse<ChatHistoryResponse>>('/ai/sessions', {
params: { page, size }
})
}
// 获取指定会话的消息历史
export const getChatMessages = (sessionId: string, page = 1, size = 50) => {
return api.get<ApiResponse<ChatMessagesResponse>>(`/ai/sessions/${sessionId}/messages`, {
params: { page, size }
})
}
// 创建新的聊天会话
export const createChatSession = (title?: string) => {
return api.post<ApiResponse<{ sessionId: string; title: string }>>('/ai/sessions', { title })
}
// 删除聊天会话
export const deleteChatSession = (sessionId: string) => {
return api.delete<ApiResponse>(`/ai/sessions/${sessionId}`)
}
// 更新会话标题
export const updateSessionTitle = (sessionId: string, title: string) => {
return api.put<ApiResponse>(`/ai/sessions/${sessionId}`, { title })
}
// 清空会话消息
export const clearSessionMessages = (sessionId: string) => {
return api.delete<ApiResponse>(`/ai/sessions/${sessionId}/messages`)
}

@ -55,6 +55,12 @@ const router = createRouter({
component: () => import('@/views/schedule/TaskView.vue'),
meta: { requiresAuth: true }
},
{
path: '/ai-assistant',
name: 'ai-assistant',
component: () => import('@/views/AIAssistantView.vue'),
meta: { requiresAuth: true }
},
{
path: '/profile',
name: 'profile',

@ -110,6 +110,8 @@ export interface Category {
status: number
createdAt: string
updatedAt: string
postCount?: number // 该分类下的帖子数量
resourceCount?: number // 该分类下的资源数量
}
// 资源相关类型

@ -0,0 +1,864 @@
<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>

@ -17,6 +17,7 @@
<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">
@ -63,6 +64,18 @@
<div class="categories-section card-light">
<h3 class="section-title">讨论分类</h3>
<div class="categories-list">
<!-- 添加全部分类选项 -->
<div
class="category-item"
:class="{ active: selectedCategory === null }"
@click="selectCategory(null)"
>
<el-icon class="category-icon">
<School />
</el-icon>
<span class="category-name">全部分类</span>
<span class="post-count">{{ totalPostsCount }}</span>
</div>
<div
v-for="category in categories"
:key="category.id"
@ -74,7 +87,7 @@
<School />
</el-icon>
<span class="category-name">{{ category.name }}</span>
<span class="post-count">0</span>
<span class="post-count">{{ category.postCount || 0 }}</span>
</div>
</div>
</div>
@ -351,6 +364,7 @@ const postFormRef = ref()
//
const categories = ref<Category[]>([])
const posts = ref<Post[]>([])
const totalPostsCount = ref(0) // ""
const pagination = ref({
page: 1,
size: 10,
@ -517,6 +531,11 @@ const loadPosts = async () => {
pagination.value.total = response.data.total || 0
pagination.value.pages = response.data.pages || 0
//
if (!selectedCategory.value && !searchKeyword.value.trim()) {
totalPostsCount.value = response.data.total || 0
}
//
console.log('加载的帖子数据:', posts.value.map(p => ({
id: p.id,
@ -545,6 +564,10 @@ const loadCategories = async () => {
if (response.code === 200) {
categories.value = response.data.list || []
console.log('分类列表加载成功:', categories.value)
//
await loadCategoryPostCounts()
} else {
console.error('加载分类失败:', response.message)
}
@ -554,10 +577,59 @@ const loadCategories = async () => {
}
}
//
const loadCategoryPostCounts = async () => {
try {
for (const category of categories.value) {
try {
const response = await getPosts({
categoryId: category.id,
page: 1,
size: 1
}) as any as ApiResponse<{
total: number
list: Post[]
pages: number
}>
if (response.code === 200) {
// postCount
category.postCount = response.data.total || 0
}
} catch (error) {
console.error(`加载分类${category.name}的帖子数量失败:`, error)
category.postCount = 0
}
}
console.log('分类帖子数量加载完成:', categories.value.map(c => ({ name: c.name, count: c.postCount })))
} catch (error) {
console.error('加载分类帖子数量失败:', error)
}
}
//
const loadTotalPostsCount = async () => {
try {
const response = await getPosts({ page: 1, size: 1 }) as any as ApiResponse<{
total: number
list: Post[]
pages: number
}>
if (response.code === 200) {
totalPostsCount.value = response.data.total || 0
}
} catch (error) {
console.error('加载总帖子数失败:', error)
}
}
onMounted(() => {
console.log('论坛页面加载完成')
loadCategories()
loadPosts()
//
loadTotalPostsCount()
})
</script>

@ -17,6 +17,7 @@
<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">

@ -16,6 +16,8 @@
<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">

@ -16,6 +16,8 @@
<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">

@ -17,6 +17,7 @@
<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">
@ -72,7 +73,7 @@
<Folder />
</el-icon>
<span class="category-name">全部资源</span>
<span class="resource-count">{{ totalResources }}</span>
<span class="resource-count">{{ allResourcesCount }}</span>
</div>
<div
v-for="category in categories"
@ -85,7 +86,7 @@
<Folder />
</el-icon>
<span class="category-name">{{ category.name }}</span>
<span class="resource-count">{{ category.count || 0 }}</span>
<span class="resource-count">{{ category.resourceCount || 0 }}</span>
</div>
</div>
</div>
@ -370,6 +371,7 @@ const currentPage = ref(1)
const pageSize = ref(10)
const totalResources = ref(0)
const totalPages = ref(0)
const allResourcesCount = ref(0) // ""
//
const resources = ref<ExtendedResource[]>([])
@ -467,6 +469,11 @@ const loadResources = async () => {
totalResources.value = response.data.total
totalPages.value = response.data.pages
//
if (!selectedCategory.value && !searchKeyword.value.trim()) {
allResourcesCount.value = response.data.total || 0
}
console.log('资源列表加载成功:', {
total: totalResources.value,
pages: totalPages.value,
@ -498,6 +505,9 @@ const loadCategories = async () => {
if (response.code === 200) {
categories.value = response.data.list
console.log('分类列表加载成功:', categories.value)
//
await loadCategoryResourceCounts()
} else {
console.error('分类列表API返回错误:', response)
ElMessage.error(response.message || '获取分类列表失败')
@ -508,6 +518,36 @@ const loadCategories = async () => {
}
}
//
const loadCategoryResourceCounts = async () => {
try {
for (const category of categories.value) {
try {
const response = await getResources({
category: category.id,
page: 1,
size: 1
}) as any as ApiResponse<{
total: number
list: any[]
pages: number
}>
if (response.code === 200) {
// resourceCount
category.resourceCount = response.data.total || 0
}
} catch (error) {
console.error(`加载分类${category.name}的资源数量失败:`, error)
category.resourceCount = 0
}
}
console.log('分类资源数量加载完成:', categories.value.map(c => ({ name: c.name, count: c.resourceCount })))
} catch (error) {
console.error('加载分类资源数量失败:', error)
}
}
//
const downloadResource = async (resource: ExtendedResource) => {
try {
@ -703,7 +743,25 @@ onMounted(async () => {
console.log('资源页面加载完成')
await loadCategories()
await loadResources()
await loadTotalResourcesCount()
})
//
const loadTotalResourcesCount = async () => {
try {
const response = await getResources({ page: 1, size: 1 }) as any as ApiResponse<{
total: number
list: any[]
pages: number
}>
if (response.code === 200) {
allResourcesCount.value = response.data.total || 0
}
} catch (error) {
console.error('加载总资源数失败:', error)
}
}
</script>
<style scoped>

@ -17,6 +17,7 @@
<router-link to="/resources" class="nav-item">资源</router-link>
<router-link to="/schedule" class="nav-item active">课程表</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">

@ -17,6 +17,7 @@
<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">

Loading…
Cancel
Save