摘要
本文详细介绍了一个功能完整的校园综合服务平台的开发过程。该项目采用前后端分离架构,后端使用Spring Boot + Spring Security + JPA + MySQL技术栈,前端使用Vue3 + TypeScript + Element Plus技术栈。平台实现了用户管理、课程管理、校园活动、二手市场、失物招领等核心功能,为校园信息化建设提供了一个完整的解决方案。
一、项目背景与意义
1.1 项目背景
随着高校信息化建设的不断深入,传统的校园管理模式已无法满足师生多样化的需求。校园中存在多个独立的信息系统,如教务系统、活动报名系统、二手交易平台等,但这些系统往往数据孤岛严重,用户体验不佳。
1.2 项目意义
一站式服务:整合校园各类服务,提供统一入口
提高效率:简化业务流程,提升管理效率
数据互通:打破数据孤岛,实现数据共享
移动优先:响应式设计,支持多终端访问
二、技术栈选型
2.1 后端技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Spring Boot | 3.1.5 | 快速开发框架 |
| Spring Security | 6.1.5 | 安全认证框架 |
| Spring Data JPA | 3.1.5 | 数据持久层 |
| MySQL | 8.0+ | 关系型数据库 |
| JWT | 0.11.5 | 令牌认证 |
| Lombok | 1.18.28 | 代码简化工具 |
| MapStruct | 1.5.5 | 对象映射 |
| Redis | 7.0+ | 缓存数据库 |
| SpringDoc | 2.2.0 | API文档 |
2.2 前端技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Vue3 | 3.3.4 | 前端框架 |
| TypeScript | 5.1.6 | 类型安全 |
| Element Plus | 2.3.8 | UI组件库 |
| Pinia | 2.1.6 | 状态管理 |
| Axios | 1.5.0 | HTTP客户端 |
| Vue Router | 4.2.4 | 路由管理 |
| VueUse | 10.3.0 | 组合式API工具集 |
| ECharts | 5.4.3 | 数据可视化 |
| Socket.IO | 4.7.2 | 实时通信 |
2.3 开发工具
IntelliJ IDEA Ultimate (后端开发)
VS Code / WebStorm (前端开发)
MySQL Workbench (数据库管理)
Postman (API测试)
Git + Git Flow (版本控制)
Docker + Docker Compose (容器化)
Jenkins (CI/CD)
三、系统架构设计
3.1 整体架构
text
复制
下载
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 前端层 │ │ 网关层 │ │ 后端层 │ │ Vue3 + TS │───▶│ Nginx/CORS │───▶│ Spring Boot │ │ Element Plus │ │ +网关限流 │ │ +安全认证 │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ ▼ ┌─────────────────┐ │ 服务层 │ │ Redis缓存 │ │ ElasticSearch │ └─────────────────┘ │ ▼ ┌─────────────────┐ │ 数据层 │ │ MySQL主从 │ │ MongoDB │ └─────────────────┘
3.2 微服务架构设计(扩展方案)
yaml
复制
下载
# 微服务拆分方案 services: - auth-service: 认证授权服务 - user-service: 用户信息服务 - course-service: 课程管理服务 - activity-service: 活动管理服务 - market-service: 二手市场服务 - message-service: 消息通知服务 - file-service: 文件存储服务 - gateway-service: API网关服务
3.3 数据库设计优化
3.3.1 分表策略
sql
复制
下载
-- 历史数据分表示例 CREATE TABLE course_history_2023 ( LIKE courses INCLUDING ALL, archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 按月分区 CREATE TABLE user_logs ( id BIGINT PRIMARY KEY, user_id BIGINT, action VARCHAR(100), created_at TIMESTAMP ) PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at));
四、核心功能实现
4.1 高级用户认证与授权
4.1.1 多因素认证实现
java
复制
下载
@Service @RequiredArgsConstructor public class MultiFactorAuthService { private final UserRepository userRepository; private final RedisTemplate<String, String> redisTemplate; private final JavaMailSender mailSender; public void sendVerificationCode(String email, AuthType type) { // 生成6位验证码 String code = RandomStringUtils.randomNumeric(6); // 存储到Redis,5分钟有效 redisTemplate.opsForValue().set( "auth:code:" + type + ":" + email, code, 5, TimeUnit.MINUTES ); // 发送邮件 SimpleMailMessage message = new SimpleMailMessage(); message.setTo(email); message.setSubject("校园平台验证码"); message.setText("您的验证码是:" + code + ",5分钟内有效"); mailSender.send(message); } public boolean verifyCode(String email, String code, AuthType type) { String storedCode = redisTemplate.opsForValue() .get("auth:code:" + type + ":" + email); return code.equals(storedCode); } public enum AuthType { LOGIN, REGISTER, RESET_PASSWORD, TRANSACTION } }4.1.2 OAuth2.0第三方登录
java
复制
下载
@Configuration public class OAuth2Config { @Bean public ClientRegistrationRepository clientRegistrationRepository() { return new InMemoryClientRegistrationRepository( ClientRegistration.withRegistrationId("github") .clientId("github-client-id") .clientSecret("github-client-secret") .scope("read:user") .authorizationUri("https://github.com/login/oauth/authorize") .tokenUri("https://github.com/login/oauth/access_token") .userInfoUri("https://api.github.com/user") .userNameAttributeName("id") .clientName("GitHub") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") .build() ); } }4.2 智能课程推荐系统
4.2.1 推荐算法实现
java
复制
下载
@Service @Slf4j public class CourseRecommendationService { private final CourseRepository courseRepository; private final UserBehaviorRepository behaviorRepository; /** * 基于协同过滤的课程推荐 */ @Async public CompletableFuture<List<Course>> recommendCourses(Long userId, int limit) { // 1. 获取用户历史行为 List<UserBehavior> userBehaviors = behaviorRepository .findByUserIdAndActionIn(userId, Arrays.asList("ENROLL", "VIEW", "LIKE")); // 2. 寻找相似用户 List<Long> similarUserIds = findSimilarUsers(userId, userBehaviors); // 3. 获取相似用户喜欢的课程 List<Course> recommendedCourses = courseRepository .findRecommendedCourses(similarUserIds, userId, limit); // 4. 基于内容过滤(课程标签匹配) List<String> userTags = extractUserTags(userBehaviors); List<Course> contentBased = contentBasedRecommendation(userTags, limit); // 5. 混合推荐 return CompletableFuture.completedFuture( hybridRecommendation(recommendedCourses, contentBased, limit) ); } /** * 基于用户画像的推荐 */ public List<Course> recommendByUserProfile(User user) { return courseRepository.createNativeQuery(""" SELECT c.* FROM courses c WHERE c.college = :college AND c.major = :major AND c.grade_level = :gradeLevel AND c.status = 'OPEN' AND c.enrolled_count < c.capacity * 0.8 ORDER BY c.rating DESC, c.enrolled_count DESC LIMIT 10 """) .setParameter("college", user.getCollege()) .setParameter("major", user.getMajor()) .setParameter("gradeLevel", calculateGradeLevel(user.getGrade())) .getResultList(); } private List<Long> findSimilarUsers(Long userId, List<UserBehavior> behaviors) { // 实现基于行为的相似度计算 // 可以使用Jaccard相似度或余弦相似度 return behaviorRepository.findSimilarUsers( userId, behaviors.stream() .map(UserBehavior::getCourseId) .collect(Collectors.toList()) ); } }4.2.2 推荐服务REST接口
java
复制
下载
@RestController @RequestMapping("/api/recommend") @RequiredArgsConstructor public class RecommendationController { private final CourseRecommendationService recommendationService; private final RedisTemplate<String, String> redisTemplate; @GetMapping("/courses") @Cacheable(value = "recommendations", key = "'user:' + #userId") public ResponseEntity<List<CourseResponse>> getRecommendations( @RequestParam(defaultValue = "10") int limit, @AuthenticationPrincipal UserDetails userDetails) { Long userId = Long.parseLong(userDetails.getUsername()); // 尝试从缓存获取 String cacheKey = "recommend:user:" + userId; String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return ResponseEntity.ok( objectMapper.readValue(cached, new TypeReference<>() {}) ); } // 实时计算推荐 List<Course> courses = recommendationService .recommendCourses(userId, limit).join(); List<CourseResponse> response = courses.stream() .map(courseMapper::toResponse) .collect(Collectors.toList()); // 缓存结果,有效期1小时 redisTemplate.opsForValue().set( cacheKey, objectMapper.writeValueAsString(response), 1, TimeUnit.HOURS ); return ResponseEntity.ok(response); } @PostMapping("/feedback") public ResponseEntity<?> feedback( @RequestBody RecommendationFeedbackRequest request) { // 收集用户反馈,优化推荐算法 recommendationService.recordFeedback( request.getUserId(), request.getCourseId(), request.getAction(), request.getScore() ); return ResponseEntity.ok().build(); } }4.3 实时通知系统
4.3.1 WebSocket配置
java
复制
下载
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic", "/queue"); config.setApplicationDestinationPrefixes("/app"); config.setUserDestinationPrefix("/user"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS() .setWebSocketEnabled(true) .setHeartbeatTime(25000) .setDisconnectDelay(5000); } @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(new AuthChannelInterceptor()); } } @Component @RequiredArgsConstructor public class NotificationService { private final SimpMessagingTemplate messagingTemplate; /** * 发送个人通知 */ public void sendPersonalNotification(Long userId, NotificationMessage message) { messagingTemplate.convertAndSendToUser( userId.toString(), "/queue/notifications", message ); } /** * 发送广播通知 */ public void sendBroadcastNotification(String topic, NotificationMessage message) { messagingTemplate.convertAndSend("/topic/" + topic, message); } /** * 发送课程相关通知 */ public void sendCourseNotification(Long courseId, NotificationType type, Object data) { NotificationMessage message = NotificationMessage.builder() .type(type) .title("课程通知") .content(buildCourseContent(type, data)) .timestamp(LocalDateTime.now()) .data(data) .build(); messagingTemplate.convertAndSend("/topic/course." + courseId, message); } @Data @Builder public static class NotificationMessage { private NotificationType type; private String title; private String content; private LocalDateTime timestamp; private Object data; private String link; } public enum NotificationType { SYSTEM, COURSE, ACTIVITY, MARKET, MESSAGE, EMERGENCY } }4.3.2 前端WebSocket集成
typescript
复制
下载
// websocket.service.ts import { ref, onUnmounted } from 'vue' import Stomp from 'stompjs' import SockJS from 'sockjs-client' import { useAuthStore } from '@/stores/auth' export class WebSocketService { private stompClient: any = null private subscriptions: Map<string, any> = new Map() private isConnected = ref(false) constructor() { this.initConnection() } private initConnection() { const authStore = useAuthStore() const socket = new SockJS('/api/ws') this.stompClient = Stomp.over(socket) // 添加认证头 this.stompClient.connect({ 'X-Authorization': `Bearer ${authStore.token}` }, () => { console.log('WebSocket connected') this.isConnected.value = true this.setupSubscriptions() }, (error: any) => { console.error('WebSocket connection error:', error) this.isConnected.value = false setTimeout(() => this.initConnection(), 5000) }) } private setupSubscriptions() { // 订阅个人通知 this.subscribe('/user/queue/notifications', (message) => { this.handleNotification(JSON.parse(message.body)) }) // 订阅课程通知 this.subscribe('/topic/course.*', (message) => { this.handleCourseNotification(JSON.parse(message.body)) }) } public subscribe(destination: string, callback: Function): string { if (!this.stompClient || !this.isConnected.value) { console.warn('WebSocket not connected') return '' } const subscription = this.stompClient.subscribe(destination, (msg: any) => { callback(msg) }) const subscriptionId = `sub-${Date.now()}` this.subscriptions.set(subscriptionId, subscription) return subscriptionId } public unsubscribe(subscriptionId: string) { const subscription = this.subscriptions.get(subscriptionId) if (subscription) { subscription.unsubscribe() this.subscriptions.delete(subscriptionId) } } public send(destination: string, body: any) { if (this.stompClient && this.isConnected.value) { this.stompClient.send(destination, {}, JSON.stringify(body)) } } public disconnect() { if (this.stompClient) { this.stompClient.disconnect() this.isConnected.value = false this.subscriptions.clear() } } private handleNotification(notification: any) { // 显示通知 ElNotification({ title: notification.title, message: notification.content, type: this.getNotificationType(notification.type), duration: 5000, onClick: () => { if (notification.link) { router.push(notification.link) } } }) // 存储到本地 this.storeNotification(notification) } private getNotificationType(type: string): any { const typeMap: Record<string, any> = { SYSTEM: 'info', COURSE: 'success', ACTIVITY: 'warning', EMERGENCY: 'error' } return typeMap[type] || 'info' } private storeNotification(notification: any) { const notifications = JSON.parse(localStorage.getItem('notifications') || '[]') notifications.unshift({ ...notification, read: false, id: Date.now() }) // 只保留最近100条 if (notifications.length > 100) { notifications.pop() } localStorage.setItem('notifications', JSON.stringify(notifications)) } } // 使用组合式API封装 export const useWebSocket = () => { const notifications = ref<any[]>([]) const unreadCount = ref(0) const wsService = new WebSocketService() const loadNotifications = () => { const stored = localStorage.getItem('notifications') if (stored) { notifications.value = JSON.parse(stored) unreadCount.value = notifications.value.filter(n => !n.read).length } } const markAsRead = (id: number) => { const index = notifications.value.findIndex(n => n.id === id) if (index !== -1) { notifications.value[index].read = true saveNotifications() unreadCount.value = notifications.value.filter(n => !n.read).length } } const markAllAsRead = () => { notifications.value.forEach(n => n.read = true) saveNotifications() unreadCount.value = 0 } const saveNotifications = () => { localStorage.setItem('notifications', JSON.stringify(notifications.value)) } onUnmounted(() => { wsService.disconnect() }) return { notifications, unreadCount, loadNotifications, markAsRead, markAllAsRead, sendMessage: wsService.send.bind(wsService), subscribe: wsService.subscribe.bind(wsService), unsubscribe: wsService.unsubscribe.bind(wsService) } }4.4 数据分析与可视化
4.4.1 数据统计服务
java
复制
下载
@Service @Transactional(readOnly = true) @RequiredArgsConstructor public class AnalyticsService { private final CourseRepository courseRepository; private final EventRepository eventRepository; private final MarketRepository marketRepository; private final UserRepository userRepository; /** * 获取平台数据概览 */ public PlatformOverview getPlatformOverview() { PlatformOverview overview = new PlatformOverview(); // 用户统计 overview.setTotalUsers(userRepository.count()); overview.setActiveUsers(getActiveUserCount()); overview.setNewUsersToday(getNewUserCountToday()); // 课程统计 overview.setTotalCourses(courseRepository.count()); overview.setActiveCourses(courseRepository.countByStatus("OPEN")); overview.setTotalEnrollments(courseRepository.sumEnrolledCount()); // 活动统计 overview.setTotalEvents(eventRepository.count()); overview.setUpcomingEvents(eventRepository.countUpcomingEvents()); // 市场统计 overview.setTotalProducts(marketRepository.count()); overview.setSoldProducts(marketRepository.countByStatus("SOLD")); return overview; } /** * 获取用户活跃度分析 */ public UserActivityAnalysis getUserActivityAnalysis(LocalDate startDate, LocalDate endDate) { return userRepository.createNativeQuery(""" SELECT DATE(created_at) as date, COUNT(*) as new_users, SUM(CASE WHEN last_login_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END) as active_users, AVG(login_count) as avg_logins FROM users WHERE created_at BETWEEN :startDate AND :endDate GROUP BY DATE(created_at) ORDER BY date """) .setParameter("startDate", startDate.atStartOfDay()) .setParameter("endDate", endDate.atTime(LocalTime.MAX)) .getResultStream() .collect(Collectors.toMap( row -> ((Date) row[0]).toLocalDate(), row -> new DailyActivity( ((Number) row[1]).longValue(), ((Number) row[2]).longValue(), ((Number) row[3]).doubleValue() ) )); } /** * 课程热度分析 */ public List<CoursePopularity> getCoursePopularityRanking(int limit) { return courseRepository.createNativeQuery(""" SELECT c.id, c.course_name, c.course_code, c.enrolled_count, COUNT(DISTINCT sc.student_id) as total_students, AVG(sc.score) as avg_score, COUNT(DISTINCT cr.id) as review_count, AVG(cr.rating) as avg_rating FROM courses c LEFT JOIN student_courses sc ON c.id = sc.course_id LEFT JOIN course_reviews cr ON c.id = cr.course_id GROUP BY c.id, c.course_name, c.course_code, c.enrolled_count ORDER BY (c.enrolled_count * 0.3 + COUNT(DISTINCT sc.student_id) * 0.2 + AVG(sc.score) * 0.2 + AVG(cr.rating) * 0.3) DESC LIMIT :limit """) .setParameter("limit", limit) .getResultList(); } /** * 生成数据分析报告 */ @Async public CompletableFuture<AnalysisReport> generateMonthlyReport(int year, int month) { return CompletableFuture.supplyAsync(() -> { AnalysisReport report = new AnalysisReport(); // 数据收集 report.setPlatformOverview(getPlatformOverview()); report.setUserActivity(getUserActivityAnalysis( LocalDate.of(year, month, 1), LocalDate.of(year, month, 1).withDayOfMonth( LocalDate.of(year, month, 1).lengthOfMonth() ) )); report.setCourseAnalysis(getCoursePopularityRanking(20)); report.setMarketAnalysis(getMarketTransactionAnalysis(year, month)); // 生成见解 report.setInsights(generateInsights(report)); // 生成建议 report.setRecommendations(generateRecommendations(report)); return report; }); } private List<String> generateInsights(AnalysisReport report) { List<String> insights = new ArrayList<>(); // 基于数据分析生成见解 if (report.getPlatformOverview().getNewUsersToday() > 100) { insights.add("今日新增用户超过100人,用户增长迅速"); } if (report.getCourseAnalysis().stream() .anyMatch(c -> c.getEnrolledCount() > c.getCapacity() * 0.9)) { insights.add("部分热门课程接近满员,建议增加容量或开设新班"); } return insights; } }4.4.2 前端数据可视化
vue
复制
下载
<template> <div class="analytics-dashboard"> <!-- 数据概览卡片 --> <el-row :gutter="20" class="overview-cards"> <el-col :xs="24" :sm="12" :md="6" v-for="card in overviewCards" :key="card.title"> <el-card class="stat-card" shadow="hover"> <div class="card-content"> <div class="stat-icon" :style="{ backgroundColor: card.color }"> <el-icon :size="24"> <component :is="card.icon" /> </el-icon> </div> <div class="stat-info"> <h3>{{ card.value }}</h3> <p>{{ card.title }}</p> <div class="trend" :class="card.trend > 0 ? 'up' : 'down'"> <el-icon> <TrendingUp v-if="card.trend > 0" /> <TrendingDown v-else /> </el-icon> <span>{{ Math.abs(card.trend) }}%</span> </div> </div> </div> </el-card> </el-col> </el-row> <!-- 图表区域 --> <el-row :gutter="20" class="chart-row"> <el-col :xs="24" :lg="12"> <el-card> <template #header> <div class="chart-header"> <h3>用户活跃度趋势</h3> <el-select v-model="activeDays" size="small"> <el-option label="最近7天" :value="7" /> <el-option label="最近30天" :value="30" /> <el-option label="最近90天" :value="90" /> </el-select> </div> </template> <div ref="userChartRef" style="height: 300px;"></div> </el-card> </el-col> <el-col :xs="24" :lg="12"> <el-card> <template #header> <h3>课程热度分布</h3> </template> <div ref="courseChartRef" style="height: 300px;"></div> </el-card> </el-col> </el-row> <!-- 详细数据表格 --> <el-card class="detail-table"> <template #header> <h3>详细数据</h3> </template> <el-table :data="detailedData" v-loading="loading"> <el-table-column prop="date" label="日期" width="120" /> <el-table-column prop="newUsers" label="新增用户" width="100" /> <el-table-column prop="activeUsers" label="活跃用户" width="100" /> <el-table-column prop="courseEnrollments" label="课程报名" width="100" /> <el-table-column prop="eventRegistrations" label="活动报名" width="100" /> <el-table-column prop="marketTransactions" label="交易数量" width="100" /> <el-table-column label="操作" width="100"> <template #default="scope"> <el-button type="text" @click="viewDetails(scope.row)"> 详情 </el-button> </template> </el-table-column> </el-table> <div class="table-footer"> <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :total="total" @current-change="loadData" /> </div> </el-card> </div> </template> <script setup lang="ts"> import { ref, onMounted, watch, nextTick } from 'vue' import * as echarts from 'echarts' import { User, Book, Calendar, ShoppingCart, TrendingUp, TrendingDown } from '@element-plus/icons-vue' import { analyticsApi } from '@/api/analytics' // 响应式数据 const overviewCards = ref([ { title: '总用户数', value: '0', icon: User, color: '#409eff', trend: 0 }, { title: '总课程数', value: '0', icon: Book, color: '#67c23a', trend: 0 }, { title: '活动数量', value: '0', icon: Calendar, color: '#e6a23c', trend: 0 }, { title: '交易数量', value: '0', icon: ShoppingCart, color: '#f56c6c', trend: 0 } ]) const activeDays = ref(7) const userChartRef = ref<HTMLElement>() const courseChartRef = ref<HTMLElement>() const detailedData = ref<any[]>([]) const loading = ref(false) const currentPage = ref(1) const pageSize = ref(10) const total = ref(0) let userChart: echarts.ECharts | null = null let courseChart: echarts.ECharts | null = null // 初始化图表 const initCharts = () => { if (userChartRef.value) { userChart = echarts.init(userChartRef.value) window.addEventListener('resize', () => userChart?.resize()) } if (courseChartRef.value) { courseChart = echarts.init(courseChartRef.value) window.addEventListener('resize', () => courseChart?.resize()) } } // 加载概览数据 const loadOverview = async () => { try { const data = await analyticsApi.getPlatformOverview() overviewCards.value[0].value = data.totalUsers.toLocaleString() overviewCards.value[0].trend = data.userGrowthRate || 0 overviewCards.value[1].value = data.totalCourses.toLocaleString() overviewCards.value[1].trend = data.courseGrowthRate || 0 overviewCards.value[2].value = data.totalEvents.toLocaleString() overviewCards.value[2].trend = data.eventGrowthRate || 0 overviewCards.value[3].value = data.totalTransactions.toLocaleString() overviewCards.value[3].trend = data.transactionGrowthRate || 0 } catch (error) { console.error('加载概览数据失败:', error) } } // 加载图表数据 const loadChartData = async () => { try { const userActivity = await analyticsApi.getUserActivity(activeDays.value) // 更新用户活跃度图表 if (userChart) { const option = { tooltip: { trigger: 'axis' }, legend: { data: ['新增用户', '活跃用户'] }, xAxis: { type: 'category', data: userActivity.map((item: any) => item.date) }, yAxis: { type: 'value' }, series: [ { name: '新增用户', type: 'line', smooth: true, data: userActivity.map((item: any) => item.newUsers), itemStyle: { color: '#409eff' } }, { name: '活跃用户', type: 'line', smooth: true, data: userActivity.map((item: any) => item.activeUsers), itemStyle: { color: '#67c23a' } } ] } userChart.setOption(option) } // 更新课程热度图表 const coursePopularity = await analyticsApi.getCoursePopularity() if (courseChart) { const option = { tooltip: { trigger: 'item' }, legend: { orient: 'vertical', left: 'left' }, series: [ { type: 'pie', radius: '50%', data: coursePopularity.map((item: any) => ({ name: item.courseName, value: item.enrolledCount })), emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } } } ] } courseChart.setOption(option) } } catch (error) { console.error('加载图表数据失败:', error) } } // 加载详细数据 const loadDetailedData = async () => { loading.value = true try { const response = await analyticsApi.getDetailedData({ page: currentPage.value, size: pageSize.value }) detailedData.value = response.data total.value = response.total } catch (error) { console.error('加载详细数据失败:', error) } finally { loading.value = false } } // 查看详情 const viewDetails = (row: any) => { // 实现详情查看逻辑 console.log('查看详情:', row) } // 监听activeDays变化 watch(activeDays, () => { loadChartData() }) // 初始化 onMounted(async () => { await nextTick() initCharts() await Promise.all([ loadOverview(), loadChartData(), loadDetailedData() ]) }) // 清理 onUnmounted(() => { if (userChart) { userChart.dispose() } if (courseChart) { courseChart.dispose() } }) </script> <style scoped lang="scss"> .analytics-dashboard { padding: 20px; .overview-cards { margin-bottom: 20px; .stat-card { .card-content { display: flex; align-items: center; .stat-icon { width: 48px; height: 48px; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 16px; color: white; } .stat-info { flex: 1; h3 { margin: 0 0 4px 0; font-size: 24px; color: #303133; } p { margin: 0 0 8px 0; color: #909399; font-size: 14px; } .trend { display: flex; align-items: center; font-size: 12px; &.up { color: #67c23a; } &.down { color: #f56c6c; } .el-icon { margin-right: 4px; } } } } } } .chart-row { margin-bottom: 20px; .chart-header { display: flex; justify-content: space-between; align-items: center; } } .detail-table { .table-footer { margin-top: 20px; display: flex; justify-content: center; } } } </script>4.5 高级搜索功能
4.5.1 使用ElasticSearch实现全文搜索
java
复制
下载
@Configuration @EnableElasticsearchRepositories public class ElasticsearchConfig extends AbstractElasticsearchConfiguration { @Value("${elasticsearch.host}") private String host; @Value("${elasticsearch.port}") private int port; @Override public RestHighLevelClient elasticsearchClient() { ClientConfiguration clientConfiguration = ClientConfiguration.builder() .connectedTo(host + ":" + port) .build(); return RestClients.create(clientConfiguration).rest(); } } @Document(indexName = "courses") @Data public class CourseIndex { @Id private Long id; @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart") private String courseName; @Field(type = FieldType.Keyword) private String courseCode; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String description; @Field(type = FieldType.Keyword) private String teacherName; @Field(type = FieldType.Keyword) private String college; @Field(type = FieldType.Integer) private Integer credit; @Field(type = FieldType.Integer) private Integer capacity; @Field(type = FieldType.Integer) private Integer enrolledCount; @Field(type = FieldType.Date) private LocalDateTime createdAt; } @Repository public