commit 0139983fc3d59695aa06ff023e8f8e979014573c Author: Schedule OCR Team Date: Wed Sep 3 21:36:49 2025 +0800 初始提交:课表OCR识别小程序完整项目 - 前端:微信小程序,支持OCR识别和课程管理 - 后端:Spring Boot,提供API服务和数据管理 - 功能:表格OCR识别、时间冲突检测、周次管理 - 文档:完整的使用教程和技术说明 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3154e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Java +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iws +*.iml +*.ipr +.vscode/ +.settings/ +.project +.classpath + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log +*.log.* + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Node.js (for miniprogram) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# WeChat MiniProgram +miniprogram_npm/ +.tea/ + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# Build outputs +dist/ +build/ + +# Configuration files with sensitive data +application-prod.yml +application-local.yml + +# Backup files +*.bak +*.backup diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb34bd2 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# 课表OCR识别小程序 + +## 📋 项目简介 + +课表OCR识别小程序是一个基于人工智能的智能课表管理系统,支持拍照识别课表并自动导入课程信息。用户可以通过拍摄纸质课表或电子课表截图,系统会自动识别并解析课程信息,大大简化了课表录入的工作。 + +## 🎯 核心功能 + +- **📸 OCR智能识别**: 使用百度AI表格文字识别,专门针对课表优化 +- **📅 课程管理**: 支持手动添加、编辑、删除课程 +- **⏰ 时间冲突检测**: 智能检测时间和周次冲突 +- **📊 周视图展示**: 按周显示课程安排,支持周次切换 +- **⚙️ 学期配置**: 支持春季/秋季学期自动配置 + +## 🛠️ 技术栈 + +### 前端 (微信小程序) +- **语言**: TypeScript +- **框架**: 微信小程序原生开发 +- **特色**: 响应式设计、用户友好界面 + +### 后端 (Spring Boot) +- **语言**: Java 8+ +- **框架**: Spring Boot 2.7.x +- **数据库**: SQLite +- **OCR服务**: 百度AI表格文字识别API + +## 🚀 快速开始 + +### 环境要求 +- JDK 8+ +- Maven 3.6+ +- 微信开发者工具 +- 百度AI开放平台账号 + +### 1. 后端启动 +```bash +cd schedule-ocr-backend +mvn spring-boot:run +``` + +### 2. 前端运行 +1. 使用微信开发者工具打开 `schedule-ocr-miniprogram` 目录 +2. 编译并运行小程序 + +### 3. 配置OCR服务 +编辑 `schedule-ocr-backend/src/main/resources/application.yml`: +```yaml +baidu: + ocr: + app-id: 你的应用ID + api-key: 你的API Key + secret-key: 你的Secret Key +``` + +## 📚 文档 + +- [📖 项目文档](./项目文档.md) - 完整的项目说明和使用教程 +- [🚀 快速开始指南](./快速开始指南.md) - 5分钟快速上手 +- [🏗️ 技术架构说明](./技术架构说明.md) - 深度技术解析 + +## 📱 使用截图 + +### 主要界面 +- **首页**: 课表展示,周次切换 +- **OCR导入**: 拍照识别,结果确认 +- **课程管理**: 添加编辑,时间设置 +- **个人中心**: 学期配置,数据管理 + +## 🎨 特色功能 + +### 表格OCR识别 +- 使用百度AI表格文字识别技术 +- 识别准确率比通用OCR提高30%+ +- 保持课表的行列结构信息 +- 支持各种课表格式和布局 + +### 智能时间管理 +- 自动检测时间冲突 +- 支持单双周课程设置 +- 周次范围灵活配置 +- 学期日期自动计算 + +## 🔧 开发说明 + +### 项目结构 +``` +├── schedule-ocr-backend/ # 后端服务 +│ ├── src/main/java/ # Java源码 +│ ├── src/main/resources/ # 配置文件 +│ └── pom.xml # Maven配置 +├── schedule-ocr-miniprogram/ # 前端小程序 +│ ├── miniprogram/pages/ # 页面文件 +│ ├── miniprogram/utils/ # 工具类 +│ └── miniprogram/app.json # 小程序配置 +└── docs/ # 项目文档 +``` + +## 📞 技术支持 + +如有问题或建议,请查看项目文档或提交Issue。 + +--- + +**版本**: v1.0.0 +**开发团队**: 课表OCR团队 +**更新时间**: 2025年9月 diff --git a/schedule-ocr-backend/OCR解析优化说明.md b/schedule-ocr-backend/OCR解析优化说明.md new file mode 100644 index 0000000..1688028 --- /dev/null +++ b/schedule-ocr-backend/OCR解析优化说明.md @@ -0,0 +1,77 @@ +# OCR课程表解析优化说明 + +## 🔧 已修复的问题 + +### 1. 表格解析逻辑完善 +- ✅ 添加了 `extractCoursesFromTableGrid` 方法 +- ✅ 实现了表格网格到课程信息的转换 +- ✅ 正确处理行列映射关系 + +### 2. 课程信息解析优化 +原始OCR数据: +``` +数据库原理与应 +用(A)[04]1-16 +周(第1-2节) +曾广平 +15号楼15307 +``` + +优化后的解析逻辑: +- **课程名称**:`数据库原理与应用(A)` (自动拼接分行的课程名) +- **教师**:`曾广平` (识别纯中文姓名) +- **教室**:`15号楼15307` (识别包含"号楼"的教室信息) +- **时间**:从节次信息中提取时间段 + +### 3. 时间解析改进 +- ✅ 优先使用OCR识别的时间信息(如:08:00~08:45) +- ✅ 备用方案:根据节次映射到标准时间段 +- ✅ 支持跨节次课程的时间计算 + +### 4. 星期映射 +- ✅ 正确映射表头的星期信息到数字 +- ✅ 支持星期一到星期日的完整映射 + +## 📊 解析流程 + +### 步骤1:表格结构解析 +``` +表格单元格[0,1]: 星期一 +表格单元格[0,2]: 星期二 +表格单元格[1,1]: 数据挖掘导论... +``` + +### 步骤2:课程信息提取 +``` +原始: 数据挖掘导论 | [01]1-16周(第 | 1-2节) | 李波 | 15号楼15220 +解析: 课程=数据挖掘导论, 教师=李波, 教室=15号楼15220 +``` + +### 步骤3:时间计算 +``` +节次: 第1节 -> 时间: 08:00-08:45 +节次: 第2节 -> 时间: 08:55-09:40 +``` + +## 🎯 预期结果 + +现在应该能正确解析出课程: +1. **数据挖掘导论** - 李波 - 15号楼15220 - 星期一 08:00-08:45 +2. **计算机网络(B)** - 周斌 - 11号楼11413 - 星期二 08:00-08:45 +3. **数据库原理与应用(A)** - 曾广平 - 15号楼15307 - 星期三 08:00-08:45 +4. **UML与软件工程建模** - 刘卫平 - 11号楼11205 - 星期四 08:00-08:45 +5. **UML与软件工程建模(实验)** - 刘卫平 - 9号楼S090204 - 星期五 08:00-08:45 + +## 🔍 调试信息 + +添加了详细的调试日志: +- 表格单元格内容 +- 课程解析过程 +- 最终解析结果 + +## 📝 测试建议 + +1. 重启后端服务 +2. 使用小程序上传课程表图片 +3. 查看后端日志中的解析结果 +4. 验证课程是否正确导入到数据库 diff --git a/schedule-ocr-backend/pom.xml b/schedule-ocr-backend/pom.xml new file mode 100644 index 0000000..bd348f0 --- /dev/null +++ b/schedule-ocr-backend/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.scheduleocr + schedule-ocr-backend + 1.0.0 + schedule-ocr-backend + 大学生课表OCR识别小程序后端服务 + + + 8 + 8 + 8 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.xerial + sqlite-jdbc + 3.42.0.0 + + + + + com.baidu.aip + java-sdk + 4.16.8 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + commons-fileupload + commons-fileupload + 1.5 + + + + + commons-io + commons-io + 2.11.0 + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java new file mode 100644 index 0000000..9ce0529 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java @@ -0,0 +1,22 @@ +package com.scheduleocr; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 大学生课表OCR识别小程序后端服务主启动类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@SpringBootApplication +public class ScheduleOcrBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(ScheduleOcrBackendApplication.class, args); + System.out.println("================================="); + System.out.println("课表OCR后端服务启动成功!"); + System.out.println("访问地址:http://localhost:8080"); + System.out.println("================================="); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java new file mode 100644 index 0000000..5c08ad5 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java @@ -0,0 +1,74 @@ +package com.scheduleocr.config; + +import com.baidu.aip.ocr.AipOcr; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 百度OCR配置类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Configuration +@ConfigurationProperties(prefix = "baidu.ocr") +public class BaiduOcrConfig { + + /** + * 百度OCR应用ID + */ + private String appId; + + /** + * 百度OCR API Key + */ + private String apiKey; + + /** + * 百度OCR Secret Key + */ + private String secretKey; + + // Getters and Setters + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + /** + * 创建百度OCR客户端Bean + * + * @return AipOcr客户端 + */ + @Bean + public AipOcr aipOcr() { + // 初始化一个AipOcr + AipOcr client = new AipOcr(appId, apiKey, secretKey); + + // 可选:设置网络连接参数 + client.setConnectionTimeoutInMillis(2000); + client.setSocketTimeoutInMillis(60000); + + return client; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java new file mode 100644 index 0000000..a1d323b --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java @@ -0,0 +1,65 @@ +package com.scheduleocr.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 跨域配置类 + * 支持微信小程序调用后端API + * + * @author scheduleocr + * @version 1.0.0 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + /** + * 配置跨域访问 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") // 允许所有域名访问 + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); // 预检请求的缓存时间 + } + + /** + * CORS配置源 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 允许所有域名 + configuration.addAllowedOriginPattern("*"); + + // 允许的HTTP方法 + configuration.addAllowedMethod("GET"); + configuration.addAllowedMethod("POST"); + configuration.addAllowedMethod("PUT"); + configuration.addAllowedMethod("DELETE"); + configuration.addAllowedMethod("OPTIONS"); + + // 允许的请求头 + configuration.addAllowedHeader("*"); + + // 允许发送Cookie + configuration.setAllowCredentials(true); + + // 预检请求的缓存时间 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + + return source; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java new file mode 100644 index 0000000..d098b2c --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java @@ -0,0 +1,20 @@ +package com.scheduleocr.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * 数据库配置类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Configuration +@EnableJpaRepositories(basePackages = "com.scheduleocr.repository") +@EnableTransactionManagement +public class DatabaseConfig { + + // SQLite数据库配置已在application.yml中完成 + // 这里主要用于启用JPA仓库和事务管理 +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java new file mode 100644 index 0000000..f5250f6 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java @@ -0,0 +1,105 @@ +package com.scheduleocr.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.Statement; + +/** + * 数据库初始化器 + * 在应用启动时创建必要的数据库表 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Component +public class DatabaseInitializer implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class); + + @Autowired + private DataSource dataSource; + + @Override + public void run(String... args) throws Exception { + log.info("开始初始化数据库表..."); + + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + + // 创建courses表 + String createTableSQL = "CREATE TABLE IF NOT EXISTS courses (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "user_openid VARCHAR(100) NOT NULL, " + + "course_name VARCHAR(100) NOT NULL, " + + "teacher_name VARCHAR(50), " + + "classroom VARCHAR(100) NOT NULL, " + + "day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), " + + "start_time VARCHAR(10) NOT NULL, " + + "end_time VARCHAR(10) NOT NULL, " + + "notes VARCHAR(500), " + + "start_week INTEGER, " + + "end_week INTEGER, " + + "week_type INTEGER, " + + "create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + + "update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + + ")"; + + statement.execute(createTableSQL); + log.info("courses表创建成功"); + + // 创建索引 + String createIndexSQL1 = "CREATE INDEX IF NOT EXISTS idx_courses_user_openid ON courses(user_openid)"; + String createIndexSQL2 = "CREATE INDEX IF NOT EXISTS idx_courses_day_time ON courses(day_of_week, start_time)"; + + statement.execute(createIndexSQL1); + statement.execute(createIndexSQL2); + log.info("数据库索引创建成功"); + + // 插入一些测试数据 + insertTestData(statement); + + log.info("数据库初始化完成!"); + + } catch (Exception e) { + log.error("数据库初始化失败", e); + throw e; + } + } + + private void insertTestData(Statement statement) throws Exception { + // 检查是否已有数据 + java.sql.ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM courses"); + if (resultSet.next() && resultSet.getInt(1) > 0) { + log.info("数据库中已有数据,跳过测试数据插入"); + return; + } + + log.info("插入测试数据..."); + + String[] testData = { + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '高等数学', '张教授', '教学楼A101', 1, '08:00', '09:40', '第1-16周')", + + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '大学英语', '李老师', '教学楼B203', 1, '10:00', '11:40', '第1-16周')", + + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '计算机程序设计', '王教授', '实验楼C301', 3, '14:00', '15:40', '第1-16周')", + + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '数据结构', '赵老师', '教学楼A205', 5, '08:00', '09:40', '第1-16周')" + }; + + for (String sql : testData) { + statement.execute(sql); + } + + log.info("测试数据插入完成"); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java new file mode 100644 index 0000000..41a6cc0 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java @@ -0,0 +1,136 @@ +package com.scheduleocr.config; + +import org.hibernate.dialect.Dialect; +import org.hibernate.dialect.function.SQLFunctionTemplate; +import org.hibernate.dialect.function.StandardSQLFunction; +import org.hibernate.dialect.function.VarArgsSQLFunction; +import org.hibernate.dialect.identity.IdentityColumnSupport; +import org.hibernate.dialect.identity.IdentityColumnSupportImpl; +import org.hibernate.type.StringType; + +import java.sql.Types; + +/** + * SQLite数据库方言 + * 适配Spring Boot 2.7的Hibernate版本 + * + * @author scheduleocr + * @version 1.0.0 + */ +public class SQLiteDialect extends Dialect { + + public SQLiteDialect() { + // 注册列类型映射 + registerColumnType(Types.BIT, "integer"); + registerColumnType(Types.TINYINT, "tinyint"); + registerColumnType(Types.SMALLINT, "smallint"); + registerColumnType(Types.INTEGER, "integer"); + registerColumnType(Types.BIGINT, "bigint"); + registerColumnType(Types.FLOAT, "float"); + registerColumnType(Types.REAL, "real"); + registerColumnType(Types.DOUBLE, "double"); + registerColumnType(Types.NUMERIC, "numeric"); + registerColumnType(Types.DECIMAL, "decimal"); + registerColumnType(Types.CHAR, "char"); + registerColumnType(Types.VARCHAR, "varchar"); + registerColumnType(Types.LONGVARCHAR, "longvarchar"); + registerColumnType(Types.DATE, "date"); + registerColumnType(Types.TIME, "time"); + registerColumnType(Types.TIMESTAMP, "timestamp"); + registerColumnType(Types.BINARY, "blob"); + registerColumnType(Types.VARBINARY, "blob"); + registerColumnType(Types.LONGVARBINARY, "blob"); + registerColumnType(Types.BLOB, "blob"); + registerColumnType(Types.CLOB, "clob"); + registerColumnType(Types.BOOLEAN, "integer"); + + // 注册函数 + registerFunction("concat", new VarArgsSQLFunction(StringType.INSTANCE, "", "||", "")); + registerFunction("mod", new SQLFunctionTemplate(StringType.INSTANCE, "?1 % ?2")); + registerFunction("substr", new StandardSQLFunction("substr", StringType.INSTANCE)); + registerFunction("substring", new StandardSQLFunction("substr", StringType.INSTANCE)); + } + + @Override + public IdentityColumnSupport getIdentityColumnSupport() { + return new SQLiteIdentityColumnSupport(); + } + + @Override + public boolean hasAlterTable() { + return false; + } + + @Override + public boolean dropConstraints() { + return false; + } + + @Override + public String getDropForeignKeyString() { + return ""; + } + + @Override + public String getAddForeignKeyConstraintString(String constraintName, + String[] foreignKey, String referencedTable, String[] primaryKey, + boolean referencesPrimaryKey) { + return ""; + } + + @Override + public String getAddPrimaryKeyConstraintString(String constraintName) { + return ""; + } + + @Override + public boolean supportsIfExistsBeforeTableName() { + return true; + } + + @Override + public boolean supportsCascadeDelete() { + return false; + } + + @Override + public boolean supportsCurrentTimestampSelection() { + return true; + } + + @Override + public String getCurrentTimestampSelectString() { + return "select current_timestamp"; + } + + @Override + public boolean isCurrentTimestampSelectStringCallable() { + return false; + } + + /** + * SQLite身份列支持 + */ + public static class SQLiteIdentityColumnSupport extends IdentityColumnSupportImpl { + + @Override + public boolean supportsIdentityColumns() { + return true; + } + + @Override + public String getIdentitySelectString(String table, String column, int type) { + return "select last_insert_rowid()"; + } + + @Override + public String getIdentityColumnString(int type) { + return "integer primary key autoincrement"; + } + + @Override + public boolean hasDataTypeInIdentityColumn() { + return false; + } + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java new file mode 100644 index 0000000..90a586f --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java @@ -0,0 +1,198 @@ +package com.scheduleocr.controller; + +import com.scheduleocr.dto.ApiResponse; +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.entity.Course; +import com.scheduleocr.service.CourseService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import java.util.List; +import java.util.Optional; + +/** + * 课程管理控制器 + * 实现课程的CRUD操作API + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/courses") +@Validated +public class CourseController { + + private static final Logger log = LoggerFactory.getLogger(CourseController.class); + + @Autowired + private CourseService courseService; + + /** + * 获取课程列表 + * GET /api/courses?userOpenid=xxx&dayOfWeek=1 + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几(可选,1-7) + * @return 课程列表 + */ + @GetMapping + public ApiResponse> getCourses( + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid, + @RequestParam(required = false) Integer dayOfWeek, + @RequestParam(required = false) Integer currentWeek) { + + try { + List courses; + if (dayOfWeek != null) { + // 获取指定星期几的课程 + if (dayOfWeek < 1 || dayOfWeek > 7) { + return ApiResponse.error("星期几必须在1-7之间"); + } + courses = courseService.getCoursesByDay(userOpenid, dayOfWeek); + } else { + // 获取所有课程 + courses = courseService.getAllCourses(userOpenid); + } + + // 如果指定了当前周次,进行周次过滤 + if (currentWeek != null && currentWeek > 0) { + courses = courseService.filterCoursesByWeek(courses, currentWeek); + } + + return ApiResponse.success("获取课程列表成功", courses); + } catch (Exception e) { + log.error("获取课程列表失败", e); + return ApiResponse.serverError("获取课程列表失败:" + e.getMessage()); + } + } + + /** + * 根据ID获取课程详情 + * GET /api/courses/{id} + * + * @param id 课程ID + * @return 课程详情 + */ + @GetMapping("/{id}") + public ApiResponse getCourse(@PathVariable Long id) { + try { + Optional course = courseService.getCourseById(id); + if (course.isPresent()) { + return ApiResponse.success("获取课程详情成功", course.get()); + } else { + return ApiResponse.error("课程不存在"); + } + } catch (Exception e) { + log.error("获取课程详情失败,id: {}", id, e); + return ApiResponse.serverError("获取课程详情失败:" + e.getMessage()); + } + } + + /** + * 新增课程 + * POST /api/courses + * + * @param courseDTO 课程数据 + * @return 创建的课程 + */ + @PostMapping + public ApiResponse createCourse(@RequestBody @Valid CourseDTO courseDTO) { + try { + Course course = courseService.createCourse(courseDTO); + return ApiResponse.success("课程创建成功", course); + } catch (RuntimeException e) { + log.warn("课程创建失败:{}", e.getMessage()); + return ApiResponse.error(e.getMessage()); + } catch (Exception e) { + log.error("课程创建失败", e); + return ApiResponse.serverError("课程创建失败:" + e.getMessage()); + } + } + + /** + * 更新课程 + * PUT /api/courses/{id} + * + * @param id 课程ID + * @param courseDTO 课程数据 + * @return 更新的课程 + */ + @PutMapping("/{id}") + public ApiResponse updateCourse(@PathVariable Long id, + @RequestBody @Valid CourseDTO courseDTO) { + try { + Course course = courseService.updateCourse(id, courseDTO); + return ApiResponse.success("课程更新成功", course); + } catch (RuntimeException e) { + log.warn("课程更新失败:{}", e.getMessage()); + return ApiResponse.error(e.getMessage()); + } catch (Exception e) { + log.error("课程更新失败,id: {}", id, e); + return ApiResponse.serverError("课程更新失败:" + e.getMessage()); + } + } + + /** + * 删除课程 + * DELETE /api/courses/{id} + * + * @param id 课程ID + * @return 删除结果 + */ + @DeleteMapping("/{id}") + public ApiResponse deleteCourse(@PathVariable Long id) { + try { + courseService.deleteCourse(id); + return ApiResponse.success(); + } catch (RuntimeException e) { + log.warn("课程删除失败:{}", e.getMessage()); + return ApiResponse.error(e.getMessage()); + } catch (Exception e) { + log.error("课程删除失败,id: {}", id, e); + return ApiResponse.serverError("课程删除失败:" + e.getMessage()); + } + } + + /** + * 清空用户所有课程 + * DELETE /api/courses/clear?userOpenid=xxx + * + * @param userOpenid 用户openid + * @return 清空结果 + */ + @DeleteMapping("/clear") + public ApiResponse clearAllCourses( + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid) { + try { + courseService.clearAllCourses(userOpenid); + return ApiResponse.success(); + } catch (Exception e) { + log.error("清空课程表失败,userOpenid: {}", userOpenid, e); + return ApiResponse.serverError("清空课程表失败:" + e.getMessage()); + } + } + + /** + * 获取用户课程统计 + * GET /api/courses/count?userOpenid=xxx + * + * @param userOpenid 用户openid + * @return 课程统计 + */ + @GetMapping("/count") + public ApiResponse getCourseCount( + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid) { + try { + long count = courseService.getCourseCount(userOpenid); + return ApiResponse.success("获取课程统计成功", count); + } catch (Exception e) { + log.error("获取课程统计失败,userOpenid: {}", userOpenid, e); + return ApiResponse.serverError("获取课程统计失败:" + e.getMessage()); + } + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java new file mode 100644 index 0000000..a135dd6 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java @@ -0,0 +1,66 @@ +package com.scheduleocr.controller; + +import com.scheduleocr.dto.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 健康检查控制器 + * 提供系统状态检查接口 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api") +public class HealthController { + + /** + * 健康检查接口 + * GET /api/health + * + * @return 系统状态信息 + */ + @GetMapping("/health") + public ApiResponse> health() { + Map status = new HashMap<>(); + status.put("status", "UP"); + status.put("timestamp", LocalDateTime.now()); + status.put("service", "schedule-ocr-backend"); + status.put("version", "1.0.0"); + + return ApiResponse.success("系统运行正常", status); + } + + /** + * 系统信息接口 + * GET /api/info + * + * @return 系统详细信息 + */ + @GetMapping("/info") + public ApiResponse> info() { + Map info = new HashMap<>(); + info.put("application", "大学生课表OCR识别小程序后端服务"); + info.put("version", "1.0.0"); + info.put("description", "支持课程表管理和OCR图片识别功能"); + info.put("author", "scheduleocr"); + info.put("timestamp", LocalDateTime.now()); + + // JVM信息 + Runtime runtime = Runtime.getRuntime(); + Map jvm = new HashMap<>(); + jvm.put("totalMemory", runtime.totalMemory() / 1024 / 1024 + " MB"); + jvm.put("freeMemory", runtime.freeMemory() / 1024 / 1024 + " MB"); + jvm.put("maxMemory", runtime.maxMemory() / 1024 / 1024 + " MB"); + jvm.put("processors", runtime.availableProcessors()); + info.put("jvm", jvm); + + return ApiResponse.success("获取系统信息成功", info); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java new file mode 100644 index 0000000..2973778 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java @@ -0,0 +1,210 @@ +package com.scheduleocr.controller; + +import com.scheduleocr.dto.ApiResponse; +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.dto.OcrResult; +import com.scheduleocr.entity.Course; +import com.scheduleocr.service.CourseService; +import com.scheduleocr.service.OcrService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import java.util.ArrayList; +import java.util.List; + +/** + * OCR识别控制器 + * 实现课表图片识别和导入功能 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/ocr") +@Validated +public class OcrController { + + private static final Logger log = LoggerFactory.getLogger(OcrController.class); + + @Autowired + private OcrService ocrService; + + @Autowired + private CourseService courseService; + + /** + * 上传并识别课表图片 + * POST /api/ocr/upload + * + * @param file 课表图片文件 + * @param userOpenid 用户openid + * @return OCR识别结果 + */ + @PostMapping("/upload") + public ApiResponse uploadAndRecognize( + @RequestParam("file") MultipartFile file, + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid) { + + try { + log.info("接收到OCR识别请求,用户: {}, 文件: {}", userOpenid, file.getOriginalFilename()); + + // 调用OCR服务进行识别 + OcrResult result = ocrService.recognizeSchedule(file, userOpenid); + + if (result.isSuccess()) { + return ApiResponse.success("课表识别成功", result); + } else { + return ApiResponse.error(result.getErrorMessage()); + } + + } catch (Exception e) { + log.error("OCR识别过程中发生错误", e); + return ApiResponse.serverError("OCR识别失败:" + e.getMessage()); + } + } + + /** + * 导入识别的课程到数据库 + * POST /api/ocr/import + * + * @param request 要导入的课程列表 + * @return 导入结果 + */ + @PostMapping("/import") + public ApiResponse importCourses( + @RequestBody @Valid ImportRequest request) { + + try { + log.info("开始导入课程,用户: {}, 课程数量: {}", + request.getUserOpenid(), request.getCourses().size()); + + List successCourses = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + + // 逐个导入课程 + for (CourseDTO courseDTO : request.getCourses()) { + try { + // 确保userOpenid一致 + courseDTO.setUserOpenid(request.getUserOpenid()); + + Course course = courseService.createCourse(courseDTO); + successCourses.add(course); + log.debug("课程导入成功: {}", courseDTO.getCourseName()); + + } catch (Exception e) { + String errorMsg = String.format("课程[%s]导入失败: %s", + courseDTO.getCourseName(), e.getMessage()); + errorMessages.add(errorMsg); + log.warn(errorMsg); + } + } + + ImportResult result = new ImportResult(); + result.setTotalCount(request.getCourses().size()); + result.setSuccessCount(successCourses.size()); + result.setFailCount(errorMessages.size()); + result.setSuccessCourses(successCourses); + result.setErrorMessages(errorMessages); + + if (successCourses.size() > 0) { + String message = String.format("课程导入完成,成功%d门,失败%d门", + successCourses.size(), errorMessages.size()); + return ApiResponse.success(message, result); + } else { + return new ApiResponse<>(400, "所有课程导入失败", result); + } + + } catch (Exception e) { + log.error("课程导入过程中发生错误", e); + return ApiResponse.serverError("课程导入失败:" + e.getMessage()); + } + } + + /** + * 导入请求DTO + */ + public static class ImportRequest { + @NotBlank(message = "用户openid不能为空") + private String userOpenid; + + @NotEmpty(message = "课程列表不能为空") + @Valid + private List courses; + + // Getters and Setters + public String getUserOpenid() { + return userOpenid; + } + + public void setUserOpenid(String userOpenid) { + this.userOpenid = userOpenid; + } + + public List getCourses() { + return courses; + } + + public void setCourses(List courses) { + this.courses = courses; + } + } + + /** + * 导入结果DTO + */ + public static class ImportResult { + private int totalCount; + private int successCount; + private int failCount; + private List successCourses; + private List errorMessages; + + // Getters and Setters + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public int getSuccessCount() { + return successCount; + } + + public void setSuccessCount(int successCount) { + this.successCount = successCount; + } + + public int getFailCount() { + return failCount; + } + + public void setFailCount(int failCount) { + this.failCount = failCount; + } + + public List getSuccessCourses() { + return successCourses; + } + + public void setSuccessCourses(List successCourses) { + this.successCourses = successCourses; + } + + public List getErrorMessages() { + return errorMessages; + } + + public void setErrorMessages(List errorMessages) { + this.errorMessages = errorMessages; + } + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java new file mode 100644 index 0000000..25df989 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java @@ -0,0 +1,136 @@ +package com.scheduleocr.controller; + +import com.baidu.aip.ocr.AipOcr; +import com.scheduleocr.dto.ApiResponse; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * OCR测试控制器 + * 用于测试百度OCR配置是否正确 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/ocr-test") +public class OcrTestController { + + private static final Logger log = LoggerFactory.getLogger(OcrTestController.class); + + @Autowired + private AipOcr aipOcr; + + /** + * 测试OCR配置 + * GET /api/ocr-test/config + * + * @return OCR配置状态 + */ + @GetMapping("/config") + public ApiResponse> testOcrConfig() { + try { + Map result = new HashMap<>(); + + // 检查AipOcr是否正确初始化 + if (aipOcr == null) { + result.put("status", "FAILED"); + result.put("message", "AipOcr未正确初始化,请检查配置"); + return new ApiResponse<>(400, "OCR配置失败", result); + } + + result.put("status", "SUCCESS"); + result.put("message", "OCR客户端初始化成功"); + result.put("note", "如需完整测试,请使用 POST /api/ocr-test/simple 上传图片"); + + log.info("OCR配置测试成功"); + return ApiResponse.success("OCR配置正常", result); + + } catch (Exception e) { + log.error("OCR配置测试失败", e); + Map result = new HashMap<>(); + result.put("status", "ERROR"); + result.put("message", "OCR配置测试异常:" + e.getMessage()); + return new ApiResponse<>(500, "OCR配置测试失败", result); + } + } + + /** + * 简单OCR测试 + * POST /api/ocr-test/simple + * + * @param imageUrl 图片URL(可选,用于测试网络图片识别) + * @return OCR测试结果 + */ + @PostMapping("/simple") + public ApiResponse> simpleOcrTest( + @RequestParam(required = false) String imageUrl) { + + try { + Map result = new HashMap<>(); + + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + // 测试网络图片识别 + HashMap options = new HashMap<>(); + options.put("language_type", "CHN_ENG"); + + JSONObject response = aipOcr.basicGeneralUrl(imageUrl, options); + + if (response.has("error_code")) { + result.put("status", "API_ERROR"); + result.put("error_code", response.get("error_code")); + result.put("error_msg", response.optString("error_msg", "未知错误")); + return new ApiResponse<>(400, "OCR API调用失败", result); + } else { + result.put("status", "SUCCESS"); + result.put("message", "OCR API调用成功"); + result.put("words_result_num", response.optInt("words_result_num", 0)); + return ApiResponse.success("OCR测试成功", result); + } + } else { + result.put("status", "NO_IMAGE"); + result.put("message", "未提供图片URL,仅测试配置"); + result.put("note", "请提供imageUrl参数进行完整测试"); + return ApiResponse.success("OCR配置正常,未进行图片识别测试", result); + } + + } catch (Exception e) { + log.error("OCR简单测试失败", e); + Map result = new HashMap<>(); + result.put("status", "EXCEPTION"); + result.put("message", "OCR测试异常:" + e.getMessage()); + return new ApiResponse<>(500, "OCR测试失败", result); + } + } + + /** + * 获取OCR使用说明 + * GET /api/ocr-test/help + * + * @return 使用说明 + */ + @GetMapping("/help") + public ApiResponse> getOcrHelp() { + Map help = new HashMap<>(); + help.put("title", "百度OCR配置说明"); + help.put("step1", "访问百度智能云控制台:https://console.bce.baidu.com/"); + help.put("step2", "搜索'文字识别OCR'并创建应用"); + help.put("step3", "获取AppID、API Key、Secret Key"); + help.put("step4", "在application.yml中配置密钥信息"); + help.put("step5", "重启应用并访问 /api/ocr-test/config 测试配置"); + + Map testApis = new HashMap<>(); + testApis.put("配置测试", "GET /api/ocr-test/config"); + testApis.put("简单测试", "POST /api/ocr-test/simple?imageUrl=图片URL"); + testApis.put("完整测试", "POST /api/ocr/upload (上传图片文件)"); + help.put("test_apis", testApis); + + return ApiResponse.success("OCR使用说明", help); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java new file mode 100644 index 0000000..89a4d22 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java @@ -0,0 +1,104 @@ +package com.scheduleocr.dto; + +/** + * 统一API响应格式 + * + * @author scheduleocr + * @version 1.0.0 + */ +public class ApiResponse { + + /** + * 响应状态码 + * 200: 成功 + * 400: 客户端错误 + * 500: 服务器错误 + */ + private Integer code; + + /** + * 响应消息 + */ + private String message; + + /** + * 响应数据 + */ + private T data; + + // Constructors + public ApiResponse() {} + + public ApiResponse(Integer code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public Integer getCode() { + return code; + } + + public void setCode(Integer code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + /** + * 成功响应 + */ + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "操作成功", data); + } + + /** + * 成功响应(无数据) + */ + public static ApiResponse success() { + return new ApiResponse<>(200, "操作成功", null); + } + + /** + * 成功响应(自定义消息) + */ + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + /** + * 失败响应 + */ + public static ApiResponse error(String message) { + return new ApiResponse<>(400, message, null); + } + + /** + * 失败响应(自定义状态码) + */ + public static ApiResponse error(Integer code, String message) { + return new ApiResponse<>(code, message, null); + } + + /** + * 服务器错误响应 + */ + public static ApiResponse serverError(String message) { + return new ApiResponse<>(500, message, null); + } + +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java new file mode 100644 index 0000000..620b80e --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java @@ -0,0 +1,196 @@ +package com.scheduleocr.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; +import javax.validation.constraints.Max; + +/** + * 课程数据传输对象 + * 用于API接口的数据传输 + * + * @author scheduleocr + * @version 1.0.0 + */ +public class CourseDTO { + + /** + * 课程ID(更新时需要) + */ + private Long id; + + /** + * 用户微信openid + */ + @NotBlank(message = "用户openid不能为空") + private String userOpenid; + + /** + * 课程名称 + */ + @NotBlank(message = "课程名称不能为空") + private String courseName; + + /** + * 任课教师 + */ + private String teacherName; + + /** + * 上课地点 + */ + @NotBlank(message = "上课地点不能为空") + private String classroom; + + /** + * 星期几 (1-7) + */ + @NotNull(message = "星期几不能为空") + @Min(value = 1, message = "星期几必须在1-7之间") + @Max(value = 7, message = "星期几必须在1-7之间") + private Integer dayOfWeek; + + /** + * 开始时间 + */ + @NotBlank(message = "开始时间不能为空") + private String startTime; + + /** + * 结束时间 + */ + @NotBlank(message = "结束时间不能为空") + private String endTime; + + /** + * 备注 + */ + private String notes; + + /** + * 开始周次 + */ + private Integer startWeek; + + /** + * 结束周次 + */ + private Integer endWeek; + + /** + * 单双周标识(0=每周,1=单周,2=双周) + */ + private Integer weekType; + + // Constructors + public CourseDTO() {} + + public CourseDTO(Long id, String userOpenid, String courseName, String teacherName, + String classroom, Integer dayOfWeek, String startTime, String endTime, String notes) { + this.id = id; + this.userOpenid = userOpenid; + this.courseName = courseName; + this.teacherName = teacherName; + this.classroom = classroom; + this.dayOfWeek = dayOfWeek; + this.startTime = startTime; + this.endTime = endTime; + this.notes = notes; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUserOpenid() { + return userOpenid; + } + + public void setUserOpenid(String userOpenid) { + this.userOpenid = userOpenid; + } + + public String getCourseName() { + return courseName; + } + + public void setCourseName(String courseName) { + this.courseName = courseName; + } + + public String getTeacherName() { + return teacherName; + } + + public void setTeacherName(String teacherName) { + this.teacherName = teacherName; + } + + public String getClassroom() { + return classroom; + } + + public void setClassroom(String classroom) { + this.classroom = classroom; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public Integer getStartWeek() { + return startWeek; + } + + public void setStartWeek(Integer startWeek) { + this.startWeek = startWeek; + } + + public Integer getEndWeek() { + return endWeek; + } + + public void setEndWeek(Integer endWeek) { + this.endWeek = endWeek; + } + + public Integer getWeekType() { + return weekType; + } + + public void setWeekType(Integer weekType) { + this.weekType = weekType; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java new file mode 100644 index 0000000..c74cf00 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java @@ -0,0 +1,111 @@ +package com.scheduleocr.dto; + +import java.util.List; + +/** + * OCR识别结果DTO + * + * @author scheduleocr + * @version 1.0.0 + */ +public class OcrResult { + + /** + * 识别是否成功 + */ + private boolean success; + + /** + * 错误消息(如果失败) + */ + private String errorMessage; + + /** + * 原始识别文本 + */ + private String rawText; + + /** + * 解析后的课程列表 + */ + private List courses; + + /** + * 识别的文本行列表 + */ + private List textLines; + + // Constructors + public OcrResult() {} + + public OcrResult(boolean success, String errorMessage, String rawText, + List courses, List textLines) { + this.success = success; + this.errorMessage = errorMessage; + this.rawText = rawText; + this.courses = courses; + this.textLines = textLines; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getRawText() { + return rawText; + } + + public void setRawText(String rawText) { + this.rawText = rawText; + } + + public List getCourses() { + return courses; + } + + public void setCourses(List courses) { + this.courses = courses; + } + + public List getTextLines() { + return textLines; + } + + public void setTextLines(List textLines) { + this.textLines = textLines; + } + + /** + * 创建成功结果 + */ + public static OcrResult success(String rawText, List textLines, List courses) { + OcrResult result = new OcrResult(); + result.setSuccess(true); + result.setRawText(rawText); + result.setTextLines(textLines); + result.setCourses(courses); + return result; + } + + /** + * 创建失败结果 + */ + public static OcrResult error(String errorMessage) { + OcrResult result = new OcrResult(); + result.setSuccess(false); + result.setErrorMessage(errorMessage); + return result; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java new file mode 100644 index 0000000..01bd387 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java @@ -0,0 +1,280 @@ +package com.scheduleocr.entity; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; +import javax.validation.constraints.Max; +import java.time.LocalDateTime; + +/** + * 课程实体类 + * 对应数据库courses表 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Entity +@Table(name = "courses") +public class Course { + + /** + * 主键ID + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 用户微信openid标识 + */ + @Column(name = "user_openid", nullable = false, length = 100) + @NotBlank(message = "用户openid不能为空") + private String userOpenid; + + /** + * 课程名称 + */ + @Column(name = "course_name", nullable = false, length = 100) + @NotBlank(message = "课程名称不能为空") + private String courseName; + + /** + * 任课教师 + */ + @Column(name = "teacher_name", length = 50) + private String teacherName; + + /** + * 上课地点 + */ + @Column(name = "classroom", nullable = false, length = 100) + @NotBlank(message = "上课地点不能为空") + private String classroom; + + /** + * 星期几 (1-7, 1表示周一,7表示周日) + */ + @Column(name = "day_of_week", nullable = false) + @NotNull(message = "星期几不能为空") + @Min(value = 1, message = "星期几必须在1-7之间") + @Max(value = 7, message = "星期几必须在1-7之间") + private Integer dayOfWeek; + + /** + * 开始时间 (格式: HH:mm,如 "08:00") + */ + @Column(name = "start_time", nullable = false, length = 10) + @NotBlank(message = "开始时间不能为空") + private String startTime; + + /** + * 结束时间 (格式: HH:mm,如 "09:40") + */ + @Column(name = "end_time", nullable = false, length = 10) + @NotBlank(message = "结束时间不能为空") + private String endTime; + + /** + * 备注信息 + */ + @Column(name = "notes", length = 500) + private String notes; + + /** + * 开始周次(如第1周) + */ + @Column(name = "start_week") + private Integer startWeek; + + /** + * 结束周次(如第16周) + */ + @Column(name = "end_week") + private Integer endWeek; + + /** + * 单双周标识(0=每周,1=单周,2=双周) + */ + @Column(name = "week_type") + private Integer weekType; + + /** + * 创建时间 + */ + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; + + /** + * 在保存前自动设置创建时间 + */ + @PrePersist + protected void onCreate() { + createTime = LocalDateTime.now(); + updateTime = LocalDateTime.now(); + } + + public Integer getStartWeek() { + return startWeek; + } + + public void setStartWeek(Integer startWeek) { + this.startWeek = startWeek; + } + + public Integer getEndWeek() { + return endWeek; + } + + public void setEndWeek(Integer endWeek) { + this.endWeek = endWeek; + } + + public Integer getWeekType() { + return weekType; + } + + public void setWeekType(Integer weekType) { + this.weekType = weekType; + } + + /** + * 在更新前自动设置更新时间 + */ + @PreUpdate + protected void onUpdate() { + updateTime = LocalDateTime.now(); + } + + // Constructors + public Course() {} + + public Course(Long id, String userOpenid, String courseName, String teacherName, + String classroom, Integer dayOfWeek, String startTime, String endTime, + String notes, LocalDateTime createTime, LocalDateTime updateTime) { + this.id = id; + this.userOpenid = userOpenid; + this.courseName = courseName; + this.teacherName = teacherName; + this.classroom = classroom; + this.dayOfWeek = dayOfWeek; + this.startTime = startTime; + this.endTime = endTime; + this.notes = notes; + this.createTime = createTime; + this.updateTime = updateTime; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUserOpenid() { + return userOpenid; + } + + public void setUserOpenid(String userOpenid) { + this.userOpenid = userOpenid; + } + + public String getCourseName() { + return courseName; + } + + public void setCourseName(String courseName) { + this.courseName = courseName; + } + + public String getTeacherName() { + return teacherName; + } + + public void setTeacherName(String teacherName) { + this.teacherName = teacherName; + } + + public String getClassroom() { + return classroom; + } + + public void setClassroom(String classroom) { + this.classroom = classroom; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } + + /** + * 获取星期几的中文名称 + */ + public String getDayOfWeekName() { + String[] dayNames = {"", "周一", "周二", "周三", "周四", "周五", "周六", "周日"}; + if (dayOfWeek >= 1 && dayOfWeek <= 7) { + return dayNames[dayOfWeek]; + } + return "未知"; + } + + /** + * 获取时间段描述 + */ + public String getTimeRange() { + return startTime + " - " + endTime; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..85f9d13 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java @@ -0,0 +1,132 @@ +package com.scheduleocr.exception; + +import com.scheduleocr.dto.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import java.util.ArrayList; +import java.util.List; + +/** + * 全局异常处理器 + * 统一处理应用中的异常,返回标准的API响应格式 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 处理参数校验异常(@Valid) + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleValidationException(MethodArgumentNotValidException e) { + log.warn("参数校验失败", e); + + List errors = new ArrayList<>(); + for (FieldError error : e.getBindingResult().getFieldErrors()) { + errors.add(error.getField() + ": " + error.getDefaultMessage()); + } + + String message = "参数校验失败: " + String.join(", ", errors); + return ApiResponse.error(400, message); + } + + /** + * 处理参数绑定异常 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleBindException(BindException e) { + log.warn("参数绑定失败", e); + + List errors = new ArrayList<>(); + for (FieldError error : e.getBindingResult().getFieldErrors()) { + errors.add(error.getField() + ": " + error.getDefaultMessage()); + } + + String message = "参数绑定失败: " + String.join(", ", errors); + return ApiResponse.error(400, message); + } + + /** + * 处理约束校验异常(@Validated) + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleConstraintViolationException(ConstraintViolationException e) { + log.warn("约束校验失败", e); + + List errors = new ArrayList<>(); + for (ConstraintViolation violation : e.getConstraintViolations()) { + errors.add(violation.getPropertyPath() + ": " + violation.getMessage()); + } + + String message = "约束校验失败: " + String.join(", ", errors); + return ApiResponse.error(400, message); + } + + /** + * 处理文件上传大小超限异常 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + log.warn("文件上传大小超限", e); + return ApiResponse.error(400, "上传文件大小超过限制,请选择小于10MB的文件"); + } + + /** + * 处理运行时异常(业务异常) + */ + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleRuntimeException(RuntimeException e) { + log.warn("业务异常", e); + return ApiResponse.error(e.getMessage()); + } + + /** + * 处理非法参数异常 + */ + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("非法参数异常", e); + return ApiResponse.error("参数错误: " + e.getMessage()); + } + + /** + * 处理空指针异常 + */ + @ExceptionHandler(NullPointerException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleNullPointerException(NullPointerException e) { + log.error("空指针异常", e); + return ApiResponse.serverError("系统内部错误,请稍后重试"); + } + + /** + * 处理其他未知异常 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleException(Exception e) { + log.error("系统异常", e); + return ApiResponse.serverError("系统内部错误: " + e.getMessage()); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java new file mode 100644 index 0000000..c73bc8d --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java @@ -0,0 +1,67 @@ +package com.scheduleocr.repository; + +import com.scheduleocr.entity.Course; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 课程数据访问接口 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Repository +public interface CourseRepository extends JpaRepository { + + /** + * 根据用户openid查询所有课程 + * + * @param userOpenid 用户openid + * @return 课程列表 + */ + List findByUserOpenidOrderByDayOfWeekAscStartTimeAsc(String userOpenid); + + /** + * 根据用户openid和星期几查询课程 + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几 + * @return 课程列表 + */ + List findByUserOpenidAndDayOfWeekOrderByStartTimeAsc(String userOpenid, Integer dayOfWeek); + + /** + * 根据用户openid删除所有课程 + * + * @param userOpenid 用户openid + */ + void deleteByUserOpenid(String userOpenid); + + /** + * 检查时间冲突 + * 查询指定用户在指定星期几的所有课程(用于在Java中进行精确时间比较) + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几 + * @param excludeId 排除的课程ID(用于更新时排除自己) + * @return 可能冲突的课程列表 + */ + @Query("SELECT c FROM Course c WHERE c.userOpenid = :userOpenid " + + "AND c.dayOfWeek = :dayOfWeek " + + "AND (:excludeId IS NULL OR c.id != :excludeId)") + List findPotentialConflictCourses(@Param("userOpenid") String userOpenid, + @Param("dayOfWeek") Integer dayOfWeek, + @Param("excludeId") Long excludeId); + + /** + * 统计用户课程总数 + * + * @param userOpenid 用户openid + * @return 课程总数 + */ + long countByUserOpenid(String userOpenid); +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java new file mode 100644 index 0000000..a6f2490 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java @@ -0,0 +1,334 @@ +package com.scheduleocr.service; + +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.entity.Course; +import com.scheduleocr.repository.CourseRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 课程服务类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Service +public class CourseService { + + private static final Logger log = LoggerFactory.getLogger(CourseService.class); + + @Autowired + private CourseRepository courseRepository; + + /** + * 获取用户所有课程 + * + * @param userOpenid 用户openid + * @return 课程列表 + */ + public List getAllCourses(String userOpenid) { + log.info("获取用户课程列表,userOpenid: {}", userOpenid); + return courseRepository.findByUserOpenidOrderByDayOfWeekAscStartTimeAsc(userOpenid); + } + + /** + * 根据星期几获取课程 + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几 + * @return 课程列表 + */ + public List getCoursesByDay(String userOpenid, Integer dayOfWeek) { + log.info("获取用户指定日期课程,userOpenid: {}, dayOfWeek: {}", userOpenid, dayOfWeek); + return courseRepository.findByUserOpenidAndDayOfWeekOrderByStartTimeAsc(userOpenid, dayOfWeek); + } + + /** + * 根据ID获取课程 + * + * @param id 课程ID + * @return 课程信息 + */ + public Optional getCourseById(Long id) { + log.info("根据ID获取课程,id: {}", id); + return courseRepository.findById(id); + } + + /** + * 创建课程 + * + * @param courseDTO 课程数据 + * @return 创建的课程 + */ + @Transactional + public Course createCourse(CourseDTO courseDTO) { + log.info("创建课程,courseName: {}", courseDTO.getCourseName()); + + // 检查时间冲突 + List potentialConflicts = courseRepository.findPotentialConflictCourses( + courseDTO.getUserOpenid(), + courseDTO.getDayOfWeek(), + null + ); + + // 在Java中进行精确的时间和周次冲突检测 + List conflictCourses = potentialConflicts.stream() + .filter(course -> isTimeConflict( + courseDTO.getStartTime(), courseDTO.getEndTime(), + course.getStartTime(), course.getEndTime() + ) && isWeekConflict( + courseDTO.getStartWeek(), courseDTO.getEndWeek(), courseDTO.getWeekType(), + course.getStartWeek(), course.getEndWeek(), course.getWeekType() + )) + .collect(Collectors.toList()); + + if (!conflictCourses.isEmpty()) { + Course conflictCourse = conflictCourses.get(0); + log.warn("时间冲突检测 - 新课程: {}({}-{}), 冲突课程: {}({}-{})", + courseDTO.getCourseName(), courseDTO.getStartTime(), courseDTO.getEndTime(), + conflictCourse.getCourseName(), conflictCourse.getStartTime(), conflictCourse.getEndTime()); + throw new RuntimeException("时间冲突:该时间段已有其他课程"); + } + + Course course = new Course(); + BeanUtils.copyProperties(courseDTO, course); + + Course savedCourse = courseRepository.save(course); + log.info("课程创建成功,id: {}", savedCourse.getId()); + return savedCourse; + } + + /** + * 更新课程 + * + * @param id 课程ID + * @param courseDTO 课程数据 + * @return 更新的课程 + */ + @Transactional + public Course updateCourse(Long id, CourseDTO courseDTO) { + log.info("更新课程,id: {}", id); + + Optional existingCourse = courseRepository.findById(id); + if (!existingCourse.isPresent()) { + throw new RuntimeException("课程不存在"); + } + + // 检查时间冲突(排除自己) + List potentialConflicts = courseRepository.findPotentialConflictCourses( + courseDTO.getUserOpenid(), + courseDTO.getDayOfWeek(), + id + ); + + // 在Java中进行精确的时间和周次冲突检测 + List conflictCourses = potentialConflicts.stream() + .filter(course -> isTimeConflict( + courseDTO.getStartTime(), courseDTO.getEndTime(), + course.getStartTime(), course.getEndTime() + ) && isWeekConflict( + courseDTO.getStartWeek(), courseDTO.getEndWeek(), courseDTO.getWeekType(), + course.getStartWeek(), course.getEndWeek(), course.getWeekType() + )) + .collect(Collectors.toList()); + + if (!conflictCourses.isEmpty()) { + Course conflictCourse = conflictCourses.get(0); + log.warn("时间冲突检测 - 更新课程: {}({}-{}), 冲突课程: {}({}-{})", + courseDTO.getCourseName(), courseDTO.getStartTime(), courseDTO.getEndTime(), + conflictCourse.getCourseName(), conflictCourse.getStartTime(), conflictCourse.getEndTime()); + throw new RuntimeException("时间冲突:该时间段已有其他课程"); + } + + Course course = existingCourse.get(); + BeanUtils.copyProperties(courseDTO, course, "id", "createTime"); + + Course savedCourse = courseRepository.save(course); + log.info("课程更新成功,id: {}", savedCourse.getId()); + return savedCourse; + } + + /** + * 删除课程 + * + * @param id 课程ID + */ + @Transactional + public void deleteCourse(Long id) { + log.info("删除课程,id: {}", id); + + if (!courseRepository.existsById(id)) { + throw new RuntimeException("课程不存在"); + } + + courseRepository.deleteById(id); + log.info("课程删除成功,id: {}", id); + } + + /** + * 清空用户所有课程 + * + * @param userOpenid 用户openid + */ + @Transactional + public void clearAllCourses(String userOpenid) { + log.info("清空用户所有课程,userOpenid: {}", userOpenid); + courseRepository.deleteByUserOpenid(userOpenid); + log.info("用户课程清空成功,userOpenid: {}", userOpenid); + } + + /** + * 获取用户课程统计 + * + * @param userOpenid 用户openid + * @return 课程总数 + */ + public long getCourseCount(String userOpenid) { + return courseRepository.countByUserOpenid(userOpenid); + } + + /** + * 根据当前周次过滤课程 + * + * @param courses 课程列表 + * @param currentWeek 当前周次 + * @return 过滤后的课程列表 + */ + public List filterCoursesByWeek(List courses, Integer currentWeek) { + if (courses == null || currentWeek == null || currentWeek <= 0) { + return courses; + } + + return courses.stream() + .filter(course -> { + // 检查周次范围 + Integer startWeek = course.getStartWeek(); + Integer endWeek = course.getEndWeek(); + + // 如果没有设置周次范围,默认显示 + if (startWeek == null || endWeek == null) { + return true; + } + + // 检查当前周是否在范围内 + if (currentWeek < startWeek || currentWeek > endWeek) { + return false; + } + + // 检查单双周 + Integer weekType = course.getWeekType(); + if (weekType == null || weekType == 0) { + return true; // 每周都显示 + } else if (weekType == 1) { + return currentWeek % 2 == 1; // 单周 + } else if (weekType == 2) { + return currentWeek % 2 == 0; // 双周 + } + + return true; + }) + .collect(Collectors.toList()); + } + + /** + * 检查两个时间段是否冲突 + * + * @param startTime1 第一个时间段的开始时间 + * @param endTime1 第一个时间段的结束时间 + * @param startTime2 第二个时间段的开始时间 + * @param endTime2 第二个时间段的结束时间 + * @return 是否冲突 + */ + private boolean isTimeConflict(String startTime1, String endTime1, String startTime2, String endTime2) { + try { + // 将时间字符串转换为分钟数进行比较 + int start1 = timeToMinutes(startTime1); + int end1 = timeToMinutes(endTime1); + int start2 = timeToMinutes(startTime2); + int end2 = timeToMinutes(endTime2); + + // 时间冲突的条件:两个时间段有重叠 + // 不冲突的条件:end1 <= start2 或 end2 <= start1 + // 冲突的条件:!(end1 <= start2 || end2 <= start1) = (end1 > start2 && end2 > start1) + boolean conflict = (end1 > start2 && end2 > start1); + + log.debug("时间冲突检测: {}({}-{}) vs {}({}-{}) = {}", + start1, end1, start2, end2, + startTime1, endTime1, startTime2, endTime2, conflict); + + return conflict; + } catch (Exception e) { + log.error("时间冲突检测失败: {}", e.getMessage()); + return false; + } + } + + /** + * 将时间字符串转换为分钟数 + * + * @param timeStr 时间字符串,格式为 "HH:mm" + * @return 分钟数 + */ + private int timeToMinutes(String timeStr) { + String[] parts = timeStr.split(":"); + int hours = Integer.parseInt(parts[0]); + int minutes = Integer.parseInt(parts[1]); + return hours * 60 + minutes; + } + + /** + * 检查两个课程的周次是否冲突 + * + * @param startWeek1 第一个课程的开始周次 + * @param endWeek1 第一个课程的结束周次 + * @param weekType1 第一个课程的单双周类型 + * @param startWeek2 第二个课程的开始周次 + * @param endWeek2 第二个课程的结束周次 + * @param weekType2 第二个课程的单双周类型 + * @return 是否冲突 + */ + private boolean isWeekConflict(Integer startWeek1, Integer endWeek1, Integer weekType1, + Integer startWeek2, Integer endWeek2, Integer weekType2) { + // 设置默认值 + int start1 = startWeek1 != null ? startWeek1 : 1; + int end1 = endWeek1 != null ? endWeek1 : 16; + int type1 = weekType1 != null ? weekType1 : 0; + + int start2 = startWeek2 != null ? startWeek2 : 1; + int end2 = endWeek2 != null ? endWeek2 : 16; + int type2 = weekType2 != null ? weekType2 : 0; + + // 检查周次范围是否有重叠 + boolean weekRangeOverlap = !(end1 < start2 || end2 < start1); + + if (!weekRangeOverlap) { + log.debug("周次范围无重叠: {}-{} vs {}-{}", start1, end1, start2, end2); + return false; // 周次范围没有重叠,不冲突 + } + + // 如果周次范围有重叠,检查单双周是否冲突 + if (type1 == 0 || type2 == 0) { + // 如果任一课程是每周都有,则冲突 + log.debug("周次冲突: 存在每周课程 type1={}, type2={}", type1, type2); + return true; + } + + if (type1 == type2) { + // 如果都是单周或都是双周,则冲突 + log.debug("周次冲突: 相同单双周类型 type1={}, type2={}", type1, type2); + return true; + } + + // 一个是单周,一个是双周,不冲突 + log.debug("周次无冲突: 不同单双周类型 type1={}, type2={}", type1, type2); + return false; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java new file mode 100644 index 0000000..9c6552a --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java @@ -0,0 +1,915 @@ +package com.scheduleocr.service; + +import com.baidu.aip.ocr.AipOcr; +import com.scheduleocr.config.BaiduOcrConfig; +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.dto.OcrResult; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.util.*; +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +/** + * OCR识别服务类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Service +public class OcrService { + + private static final Logger log = LoggerFactory.getLogger(OcrService.class); + + @Autowired + private AipOcr aipOcr; + + @Autowired + private BaiduOcrConfig baiduConfig; + + /** + * 识别课表图片 + * + * @param file 上传的图片文件 + * @param userOpenid 用户openid + * @return OCR识别结果 + */ + public OcrResult recognizeSchedule(MultipartFile file, String userOpenid) { + try { + log.info("开始OCR识别,文件名: {}, 用户: {}", file.getOriginalFilename(), userOpenid); + + // 检查文件 + if (file.isEmpty()) { + return OcrResult.error("上传文件为空"); + } + + // 检查文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return OcrResult.error("请上传图片文件"); + } + + // 检查文件大小(10MB限制) + if (file.getSize() > 10 * 1024 * 1024) { + return OcrResult.error("图片文件不能超过10MB"); + } + + // 调用百度OCR API + byte[] imageBytes = file.getBytes(); + + // 验证图片格式 + validateImageFormat(file, imageBytes); + + // 首先尝试表格识别API + JSONObject response = null; + try { + response = callTableRecognitionAPI(imageBytes); + log.info("使用表格识别API成功"); + } catch (Exception e) { + log.warn("表格识别API调用失败,回退到通用识别: {}", e.getMessage()); + // 回退到通用识别 + HashMap options = new HashMap<>(); + options.put("language_type", "CHN_ENG"); + options.put("detect_direction", "true"); + options.put("detect_language", "true"); + options.put("probability", "true"); + response = aipOcr.basicGeneral(imageBytes, options); + } + log.info("百度OCR响应: {}", response.toString()); + + // 格式化显示OCR识别的文本内容 + formatAndLogOCRResults(response); + + // 检查API调用是否成功 + if (response.has("error_code")) { + String errorMsg = response.optString("error_msg", "OCR识别失败"); + log.error("百度OCR API错误: {}", errorMsg); + return OcrResult.error("OCR识别失败: " + errorMsg); + } + + // 解析识别结果 + List textLines = new ArrayList<>(); + StringBuilder rawText = new StringBuilder(); + + // 检查是否是表格识别结果 + if (response.has("tables_result")) { + // 表格识别结果 + JSONArray tablesResult = response.optJSONArray("tables_result"); + if (tablesResult != null && tablesResult.length() > 0) { + JSONObject table = tablesResult.getJSONObject(0); // 取第一个表格 + JSONArray bodyArray = table.optJSONArray("body"); + if (bodyArray != null) { + for (int i = 0; i < bodyArray.length(); i++) { + JSONObject cell = bodyArray.getJSONObject(i); + String words = cell.optString("words", ""); + if (!words.trim().isEmpty()) { + textLines.add(words.trim()); + rawText.append(words.trim()).append("\n"); + } + } + } + } + } else { + // 通用OCR结果 + JSONArray wordsResult = response.optJSONArray("words_result"); + if (wordsResult != null) { + for (int i = 0; i < wordsResult.length(); i++) { + JSONObject wordInfo = wordsResult.getJSONObject(i); + String words = wordInfo.optString("words", ""); + if (!words.trim().isEmpty()) { + textLines.add(words.trim()); + rawText.append(words.trim()).append("\n"); + } + } + } + } + + // 解析课程信息 - 只使用表格识别方法 + List courses; + + if (response.has("tables_result")) { + courses = parseCoursesFromTableResult(response, userOpenid); + log.info("使用表格解析方法,成功解析出 {} 门课程", courses.size()); + } else { + log.warn("OCR响应中没有表格识别结果,无法解析课程"); + courses = new ArrayList<>(); + } + + log.info("OCR识别完成,识别到{}行文本,解析出{}门课程", textLines.size(), courses.size()); + return OcrResult.success(rawText.toString(), textLines, courses); + + } catch (IOException e) { + log.error("读取上传文件失败", e); + return OcrResult.error("读取上传文件失败: " + e.getMessage()); + } catch (Exception e) { + log.error("OCR识别过程中发生错误", e); + return OcrResult.error("OCR识别失败: " + e.getMessage()); + } + } + + /** + * 格式化并记录OCR识别结果 + */ + private void formatAndLogOCRResults(JSONObject response) { + if (response.has("words_result")) { + JSONArray wordsResult = response.getJSONArray("words_result"); + log.info("=== OCR识别文本内容 ==="); + for (int i = 0; i < wordsResult.length(); i++) { + JSONObject wordItem = wordsResult.getJSONObject(i); + String words = wordItem.getString("words"); + log.info("第{}行: {}", i + 1, words); + } + log.info("=== 共识别{}行文本 ===", wordsResult.length()); + } + } + /** + * 课程信息类 + */ + private static class CourseInfo { + String courseName; + String teacher; + String classroom; + + CourseInfo(String courseName, String teacher, String classroom) { + this.courseName = courseName; + this.teacher = teacher; + this.classroom = classroom; + } + } + + + + /** + * 调用百度OCR表格识别V2 API + */ + private JSONObject callTableRecognitionAPI(byte[] imageBytes) throws Exception { + log.info("开始调用表格识别API,图片大小: {} bytes", imageBytes.length); + + // 验证图片大小 + if (imageBytes.length > 4 * 1024 * 1024) { // 4MB限制 + throw new RuntimeException("图片文件过大,请压缩后重试(最大4MB)"); + } + if (imageBytes.length < 100) { // 最小100字节 + throw new RuntimeException("图片文件过小,请检查文件是否完整"); + } + + // 获取access_token + String accessToken = getAccessToken(); + + // 构建请求URL + String url = "https://aip.baidubce.com/rest/2.0/ocr/v1/table?access_token=" + accessToken; + + // 将图片转换为base64(不进行URL编码,直接传递) + String base64Image = Base64.getEncoder().encodeToString(imageBytes); + log.debug("Base64图片长度: {}", base64Image.length()); + + // 构建请求体 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("image", base64Image); // 直接使用base64,不进行URL编码 + params.add("cell_contents", "true"); // 输出单元格文字位置信息 + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + // 创建请求实体 + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + // 发送请求 + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); + + log.info("表格识别API响应状态: {}", response.getStatusCode()); + + if (response.getStatusCode() == HttpStatus.OK) { + JSONObject result = new JSONObject(response.getBody()); + + // 检查API响应中的错误 + if (result.has("error_code")) { + String errorMsg = result.optString("error_msg", "未知错误"); + int errorCode = result.optInt("error_code", 0); + log.error("百度表格识别API错误 - 错误码: {}, 错误信息: {}", errorCode, errorMsg); + + // 提供更友好的错误信息 + String friendlyMsg = getFriendlyErrorMessage(errorCode, errorMsg); + throw new RuntimeException(friendlyMsg); + } + + return result; + } else { + throw new RuntimeException("表格识别API调用失败: " + response.getStatusCode()); + } + } + + /** + * 获取友好的错误信息 + */ + private String getFriendlyErrorMessage(int errorCode, String errorMsg) { + switch (errorCode) { + case 216201: + return "图片格式错误,请上传JPG、PNG、BMP格式的图片"; + case 216202: + return "图片文件过大,请压缩后重试(最大4MB)"; + case 216630: + return "识别错误,请确保图片清晰且包含表格内容"; + case 17: + return "每日调用量超限,请明天再试或升级套餐"; + case 18: + return "QPS超限,请稍后重试"; + case 19: + return "请求总量超限,请升级套餐"; + default: + return "OCR识别失败: " + errorMsg; + } + } + + /** + * 获取百度API的access_token + */ + private String getAccessToken() throws Exception { + String url = "https://aip.baidubce.com/oauth/2.0/token"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + params.add("client_id", baiduConfig.getApiKey()); + params.add("client_secret", baiduConfig.getSecretKey()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + JSONObject jsonResponse = new JSONObject(response.getBody()); + return jsonResponse.getString("access_token"); + } else { + throw new RuntimeException("获取access_token失败: " + response.getStatusCode()); + } + } + + /** + * 从表格识别结果中解析课程信息 + */ + private List parseCoursesFromTableResult(JSONObject response, String userOpenid) { + List courses = new ArrayList<>(); + + try { + JSONArray tablesResult = response.optJSONArray("tables_result"); + if (tablesResult == null || tablesResult.length() == 0) { + log.warn("表格识别结果为空"); + return courses; + } + + // 取第一个表格 + JSONObject table = tablesResult.getJSONObject(0); + JSONArray bodyArray = table.optJSONArray("body"); + if (bodyArray == null) { + log.warn("表格body为空"); + return courses; + } + + // 构建表格结构:行列映射 + Map> tableGrid = new HashMap<>(); + + // 解析每个单元格 + for (int i = 0; i < bodyArray.length(); i++) { + JSONObject cell = bodyArray.getJSONObject(i); + String words = cell.optString("words", "").trim(); + int rowStart = cell.optInt("row_start", -1); + int colStart = cell.optInt("col_start", -1); + + if (!words.isEmpty() && rowStart >= 0 && colStart >= 0) { + String rowKey = "row_" + rowStart; + String colKey = "col_" + colStart; + + tableGrid.computeIfAbsent(rowKey, k -> new HashMap<>()).put(colKey, words); + log.debug("表格单元格[{},{}]: {}", rowStart, colStart, words); + } + } + + // 解析课程信息 + courses = extractCoursesFromTableGrid(tableGrid, userOpenid); + + } catch (Exception e) { + log.error("解析表格识别结果时发生错误", e); + } + + return courses; + } + + /** + * 从表格网格中提取课程信息 + */ + private List extractCoursesFromTableGrid(Map> tableGrid, String userOpenid) { + List courses = new ArrayList<>(); + + try { + // 星期映射 + Map dayMapping = new HashMap<>(); + dayMapping.put("星期一", 1); + dayMapping.put("星期二", 2); + dayMapping.put("星期三", 3); + dayMapping.put("星期四", 4); + dayMapping.put("星期五", 5); + dayMapping.put("星期六", 6); + dayMapping.put("星期日", 7); + + // 获取表头信息(第0行) + Map headerRow = tableGrid.get("row_0"); + if (headerRow == null) { + log.warn("找不到表头行"); + return courses; + } + + // 建立列索引到星期的映射 + Map colToDayMapping = new HashMap<>(); + for (Map.Entry entry : headerRow.entrySet()) { + String colKey = entry.getKey(); + String cellValue = entry.getValue(); + + if (colKey.startsWith("col_")) { + int colIndex = Integer.parseInt(colKey.substring(4)); + Integer dayOfWeek = dayMapping.get(cellValue); + if (dayOfWeek != null) { + colToDayMapping.put(colIndex, dayOfWeek); + log.debug("列{}对应{}", colIndex, cellValue); + } + } + } + + // 遍历数据行(从第1行开始) + for (int rowIndex = 1; rowIndex <= 10; rowIndex++) { // 假设最多10行 + Map row = tableGrid.get("row_" + rowIndex); + if (row == null) continue; + + // 获取时间信息(第0列) + String timeInfo = row.get("col_0"); + if (timeInfo == null || timeInfo.isEmpty()) continue; + + // 解析节次信息 + String[] timeLines = timeInfo.split("\n"); + String periodInfo = ""; + String timeRange = ""; + + for (String line : timeLines) { + if (line.contains("第") && line.contains("节")) { + periodInfo = line.trim(); + } else if (line.contains(":") && line.contains("~")) { + timeRange = line.replace("(", "").replace(")", "").trim(); + } + } + + if (periodInfo.isEmpty()) continue; + + // 解析节次数字 + int startPeriod = extractPeriodNumber(periodInfo); + if (startPeriod <= 0) continue; + + // 遍历每一列(星期) + for (Map.Entry dayEntry : colToDayMapping.entrySet()) { + int colIndex = dayEntry.getKey(); + int dayOfWeek = dayEntry.getValue(); + + String courseInfo = row.get("col_" + colIndex); + if (courseInfo == null || courseInfo.trim().isEmpty()) continue; + + // 解析课程信息(可能包含多个节次和多个周次) + List parsedCourses = parseCourseInfo(courseInfo, userOpenid, dayOfWeek, startPeriod, timeRange); + if (parsedCourses != null && !parsedCourses.isEmpty()) { + courses.addAll(parsedCourses); + log.debug("解析到{}门课程: {} - {} - 第{}节", parsedCourses.size(), + parsedCourses.get(0).getCourseName(), getDayName(dayOfWeek), startPeriod); + } + } + } + + } catch (Exception e) { + log.error("从表格网格提取课程信息时发生错误", e); + } + + return courses; + } + + /** + * 从节次信息中提取节次数字 + */ + private int extractPeriodNumber(String periodInfo) { + try { + // 匹配"第X节"格式 + if (periodInfo.contains("第") && periodInfo.contains("节")) { + String numberStr = periodInfo.replaceAll("[^0-9]", ""); + if (!numberStr.isEmpty()) { + return Integer.parseInt(numberStr); + } + } + } catch (Exception e) { + log.debug("解析节次数字失败: {}", periodInfo); + } + return 0; + } + + /** + * 解析课程信息(支持多节次和多周次) + */ + private List parseCourseInfo(String courseInfo, String userOpenid, int dayOfWeek, int startPeriod, String timeRange) { + List courses = new ArrayList<>(); + try { + String[] lines = courseInfo.split("\n"); + if (lines.length < 1) return courses; + + // 重新组合完整的课程信息并预处理 + String fullText = String.join("", lines); + fullText = preprocessOcrText(fullText); + log.debug("完整文本: {}", fullText); + + // 同时预处理每一行 + for (int i = 0; i < lines.length; i++) { + lines[i] = preprocessOcrText(lines[i]); + } + + // 解析各个部分 + String courseName = ""; + String teacher = ""; + String classroom = ""; + String weekInfo = ""; + String classInfo = ""; + + // 增强的教室识别逻辑 + for (String line : lines) { + line = line.trim(); + // 匹配多种教室格式:号楼、S开头的实验室、纯数字教室等 + if (line.matches(".*\\d+号楼.*") || + line.matches(".*S\\d+.*") || + line.matches(".*[A-Z]\\d+.*") || + (line.matches("\\d{4,}") && line.length() <= 6)) { + classroom = line; + break; + } + } + + // 增强的教师识别逻辑 + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + + // 如果找到了教室,教师通常在教室前面 + if (!classroom.isEmpty() && line.equals(classroom)) { + // 向前查找教师 + for (int j = i - 1; j >= 0; j--) { + String prevLine = lines[j].trim(); + if (isTeacherName(prevLine)) { + teacher = prevLine; + break; + } + } + break; + } + + // 直接匹配教师姓名模式 + if (isTeacherName(line)) { + teacher = line; + } + } + + // 增强的周次和节次信息收集 + for (String line : lines) { + line = line.trim(); + if (line.contains("周") || line.contains("节") || + line.matches(".*\\[\\d+\\].*") || + line.contains("单") || line.contains("双")) { + weekInfo += line + " "; + } + } + + // 增强的课程名称组合逻辑 + StringBuilder courseNameBuilder = new StringBuilder(); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty() || line.equals(teacher) || line.equals(classroom)) { + continue; + } + // 跳过明显的周次、节次、时间信息 + if (line.contains("周") || line.contains("节") || + line.matches(".*\\d{2}:\\d{2}.*") || + line.matches(".*\\[\\d+\\].*")) { + continue; + } + courseNameBuilder.append(line); + } + courseName = courseNameBuilder.toString(); + + // 从课程名称中分离出班级信息 + if (courseName.contains("[") && courseName.contains("]")) { + int startIndex = courseName.indexOf("["); + int endIndex = courseName.indexOf("]"); + if (startIndex < endIndex) { + classInfo = courseName.substring(startIndex, endIndex + 1); + courseName = courseName.substring(0, startIndex).trim(); + } + } + + weekInfo = weekInfo.trim(); + + if (courseName.isEmpty()) return courses; + + log.debug("解析课程信息 - 原始: {}", courseInfo.replaceAll("\n", " | ")); + log.debug("解析结果 - 课程: {}, 教师: {}, 教室: {}", courseName, teacher, classroom); + log.debug("周次节次信息: {}", weekInfo); + + // 解析节次信息(可能是范围,如"第1-2节") + List periods = parsePeriodRange(weekInfo); + log.debug("解析到的节次: {}", periods); + if (periods.isEmpty()) { + periods.add(startPeriod); // 默认使用传入的节次 + } + + // 解析周次信息(如"1-16周") + List weeks = parseWeekRange(weekInfo); + String weekRange = ""; + Integer startWeek = 1; + Integer endWeek = 16; + if (!weeks.isEmpty()) { + startWeek = weeks.get(0); + endWeek = weeks.get(weeks.size() - 1); + weekRange = startWeek + "-" + endWeek + "周"; + } else { + weekRange = "1-16周"; // 默认整个学期 + } + + // 解析单双周信息 + Integer weekType = parseWeekType(weekInfo); + + // 为每个节次创建独立的课程记录 + String[] timeSlots = getTimeSlots(); + + for (Integer period : periods) { + String startTime = ""; + String endTime = ""; + + // 根据节次获取对应的时间段(每个节次使用自己的时间) + if (period >= 1 && period <= timeSlots.length) { + String[] timeSlot = timeSlots[period - 1].split("-"); + startTime = timeSlot[0]; + endTime = timeSlot[1]; + } else { + log.warn("无效的节次: {}, 跳过", period); + continue; + } + + CourseDTO course = new CourseDTO(); + course.setCourseName(courseName); + course.setTeacherName(teacher); + course.setClassroom(classroom); + course.setDayOfWeek(dayOfWeek); + course.setStartTime(startTime); + course.setEndTime(endTime); + course.setUserOpenid(userOpenid); + + // 设置周次信息 + course.setStartWeek(startWeek); + course.setEndWeek(endWeek); + course.setWeekType(weekType); + + // 添加班级和周次信息到备注 + String notes = ""; + if (!classInfo.isEmpty()) { + notes += classInfo + " "; + } + notes += weekRange; + if (weekType == 1) { + notes += "(单)"; + } else if (weekType == 2) { + notes += "(双)"; + } + course.setNotes(notes.trim()); + + courses.add(course); + } + + return courses; + + } catch (Exception e) { + log.debug("解析课程信息失败: {}", courseInfo, e); + return courses; + } + } + + /** + * 判断是否为教师姓名 + */ + private boolean isTeacherName(String text) { + if (text == null || text.trim().isEmpty()) { + return false; + } + text = text.trim(); + + // 长度在2-4个字符之间 + if (text.length() < 2 || text.length() > 4) { + return false; + } + + // 全部是中文字符 + if (!text.matches("[\u4e00-\u9fa5]+")) { + return false; + } + + // 排除一些明显不是姓名的词 + String[] excludeWords = {"周一", "周二", "周三", "周四", "周五", "周六", "周日", + "上午", "下午", "晚上", "单周", "双周", "实验", "理论", "课程"}; + for (String exclude : excludeWords) { + if (text.contains(exclude)) { + return false; + } + } + + return true; + } + + /** + * 解析节次范围(如"第1-2节") + */ + private List parsePeriodRange(String info) { + List periods = new ArrayList<>(); + try { + if (info != null && info.contains("第") && info.contains("节")) { + // 匹配"第X-Y节"或"第X节",支持空格 + String periodPart = info.replaceAll(".*第\\s*([0-9-]+)\\s*节.*", "$1"); + log.debug("提取的节次部分: {}", periodPart); + + if (periodPart.contains("-")) { + String[] range = periodPart.split("-"); + if (range.length == 2) { + int start = Integer.parseInt(range[0].trim()); + int end = Integer.parseInt(range[1].trim()); + for (int i = start; i <= end; i++) { + periods.add(i); + } + } + } else if (periodPart.matches("\\d+")) { + periods.add(Integer.parseInt(periodPart)); + } + } + } catch (Exception e) { + log.debug("解析节次范围失败: {}, 错误: {}", info, e.getMessage()); + } + return periods; + } + + /** + * 解析周次范围(如"1-16周") + */ + private List parseWeekRange(String info) { + List weeks = new ArrayList<>(); + try { + if (info != null && info.contains("周")) { + log.debug("解析周次信息: {}", info); + + // 匹配多种周次格式 + Pattern[] patterns = { + Pattern.compile("(\\d+)-(\\d+)周"), // "1-16周" + Pattern.compile("\\[(\\d+)\\]\\s*(\\d+)-(\\d+)"), // "[01] 1-16" + Pattern.compile("第(\\d+)-(\\d+)周"), // "第1-16周" + Pattern.compile("(\\d+)~(\\d+)周"), // "1~16周" + Pattern.compile("(\\d+)至(\\d+)周"), // "1至16周" + Pattern.compile("(\\d+)\\s*-\\s*(\\d+)") // "1 - 16" + }; + + boolean found = false; + for (Pattern pattern : patterns) { + Matcher matcher = pattern.matcher(info); + if (matcher.find()) { + int start, end; + if (pattern.pattern().contains("\\[(\\d+)\\]")) { + // 特殊格式 "[01] 1-16" + start = Integer.parseInt(matcher.group(2)); + end = Integer.parseInt(matcher.group(3)); + } else { + start = Integer.parseInt(matcher.group(1)); + end = Integer.parseInt(matcher.group(2)); + } + log.debug("匹配到周次范围: {}-{}", start, end); + for (int i = start; i <= end; i++) { + weeks.add(i); + } + found = true; + break; + } + } + + // 如果没有匹配到范围,尝试匹配单个周次 + if (!found) { + Pattern[] singlePatterns = { + Pattern.compile("第?(\\d+)周"), + Pattern.compile("\\[(\\d+)\\]"), + Pattern.compile("(\\d+)") + }; + + for (Pattern pattern : singlePatterns) { + Matcher matcher = pattern.matcher(info); + if (matcher.find()) { + int week = Integer.parseInt(matcher.group(1)); + log.debug("匹配到单个周次: {}", week); + weeks.add(week); + found = true; + break; + } + } + } + + if (!found) { + log.debug("未能匹配到周次信息"); + } + } + } catch (Exception e) { + log.debug("解析周次范围失败: {}, 错误: {}", info, e.getMessage()); + } + return weeks; + } + + /** + * 解析单双周类型 + * @param info 包含周次信息的字符串 + * @return 0=每周,1=单周,2=双周 + */ + private Integer parseWeekType(String info) { + if (info == null) return 0; + + String lowerInfo = info.toLowerCase(); + if (lowerInfo.contains("(单)") || lowerInfo.contains("(单)") || lowerInfo.contains("单周")) { + return 1; // 单周 + } else if (lowerInfo.contains("(双)") || lowerInfo.contains("(双)") || lowerInfo.contains("双周")) { + return 2; // 双周 + } + return 0; // 每周 + } + + /** + * 获取星期名称 + */ + private String getDayName(int dayOfWeek) { + String[] dayNames = {"", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"}; + if (dayOfWeek >= 1 && dayOfWeek <= 7) { + return dayNames[dayOfWeek]; + } + return "未知"; + } + + /** + * 获取时间段映射 + */ + private String[] getTimeSlots() { + return new String[]{ + "08:00-08:45", // 第1节 + "08:55-09:40", // 第2节 + "10:00-10:45", // 第3节 + "10:55-11:40", // 第4节 + "14:10-14:55", // 第5节 + "15:05-15:50", // 第6节 + "16:00-16:45", // 第7节 + "16:55-17:40", // 第8节 + "18:40-19:25", // 第9节 + "19:30-20:15", // 第10节 + "20:20-21:05" // 第11节 + }; + } + + /** + * 预处理OCR识别的文本 + */ + private String preprocessOcrText(String text) { + if (text == null) return ""; + + return text + .replaceAll("\\s+", " ") // 多个空白字符替换为单个空格 + .replaceAll("([0-9])\\s+([0-9])", "$1$2") // 数字间的空格去除 + .replaceAll("([a-zA-Z])\\s+([a-zA-Z])", "$1$2") // 字母间的空格去除 + .replaceAll("([\\u4e00-\\u9fa5])\\s+([\\u4e00-\\u9fa5])", "$1$2") // 中文字符间的空格去除 + .replaceAll("(\\d+)\\s*-\\s*(\\d+)", "$1-$2") // 数字范围中的空格去除 + .replaceAll("(\\d+)\\s*~\\s*(\\d+)", "$1~$2") // 波浪号范围中的空格去除 + .replaceAll("第\\s*(\\d+)", "第$1") // "第 X" -> "第X" + .replaceAll("(\\d+)\\s*号楼", "$1号楼") // "X 号楼" -> "X号楼" + .replaceAll("S\\s*(\\d+)", "S$1") // "S X" -> "SX" + .trim(); + } + + /** + * 验证图片格式 + */ + private void validateImageFormat(MultipartFile file, byte[] imageBytes) throws Exception { + String originalFilename = file.getOriginalFilename(); + String contentType = file.getContentType(); + + log.info("上传文件信息 - 文件名: {}, 内容类型: {}, 大小: {} bytes", + originalFilename, contentType, imageBytes.length); + + // 检查文件大小 + if (imageBytes.length > 4 * 1024 * 1024) { // 4MB + throw new RuntimeException("图片文件过大,请压缩后重试(最大4MB)"); + } + if (imageBytes.length < 100) { + throw new RuntimeException("图片文件过小,请检查文件是否完整"); + } + + // 检查文件扩展名 + if (originalFilename != null) { + String extension = originalFilename.toLowerCase(); + if (!extension.endsWith(".jpg") && !extension.endsWith(".jpeg") && + !extension.endsWith(".png") && !extension.endsWith(".bmp")) { + throw new RuntimeException("不支持的图片格式,请上传JPG、PNG或BMP格式的图片"); + } + } + + // 检查MIME类型 + if (contentType != null && !contentType.startsWith("image/")) { + throw new RuntimeException("上传的文件不是图片格式"); + } + + // 检查图片文件头(魔数) + if (!isValidImageHeader(imageBytes)) { + throw new RuntimeException("图片文件损坏或格式不正确"); + } + + log.info("图片格式验证通过"); + } + + /** + * 检查图片文件头(魔数) + */ + private boolean isValidImageHeader(byte[] imageBytes) { + if (imageBytes.length < 4) return false; + + // JPEG: FF D8 FF + if (imageBytes[0] == (byte)0xFF && imageBytes[1] == (byte)0xD8 && imageBytes[2] == (byte)0xFF) { + return true; + } + + // PNG: 89 50 4E 47 + if (imageBytes[0] == (byte)0x89 && imageBytes[1] == (byte)0x50 && + imageBytes[2] == (byte)0x4E && imageBytes[3] == (byte)0x47) { + return true; + } + + // BMP: 42 4D + if (imageBytes[0] == (byte)0x42 && imageBytes[1] == (byte)0x4D) { + return true; + } + + return false; + } + + +} diff --git a/schedule-ocr-backend/src/main/resources/application-example.yml b/schedule-ocr-backend/src/main/resources/application-example.yml new file mode 100644 index 0000000..eccdaf5 --- /dev/null +++ b/schedule-ocr-backend/src/main/resources/application-example.yml @@ -0,0 +1,56 @@ +# 百度OCR配置示例文件 +# 请复制此文件为 application-local.yml 并填入真实的密钥信息 +# 然后使用 --spring.profiles.active=local 启动应用 + +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: schedule-ocr-backend + + # SQLite数据库配置 + datasource: + url: jdbc:sqlite:schedule.db + driver-class-name: org.sqlite.JDBC + username: + password: + + # JPA配置 + jpa: + database-platform: com.scheduleocr.config.SQLiteDialect + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: com.scheduleocr.config.SQLiteDialect + + # 文件上传配置 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + +# 百度OCR配置 - 请填入真实的密钥信息 +baidu: + ocr: + # 示例:app-id: "12345678" + app-id: "YOUR_REAL_APP_ID" + # 示例:api-key: "abcdefghijklmnopqrstuvwxyz123456" + api-key: "YOUR_REAL_API_KEY" + # 示例:secret-key: "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456" + secret-key: "YOUR_REAL_SECRET_KEY" + +# 日志配置 +logging: + level: + com.scheduleocr: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/schedule-ocr-backend/src/main/resources/application.yml b/schedule-ocr-backend/src/main/resources/application.yml new file mode 100644 index 0000000..4cef6ab --- /dev/null +++ b/schedule-ocr-backend/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: schedule-ocr-backend + + # SQLite数据库配置 + datasource: + url: jdbc:sqlite:schedule.db + driver-class-name: org.sqlite.JDBC + username: + password: + + # JPA配置 + jpa: + database-platform: com.scheduleocr.config.SQLiteDialect + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: com.scheduleocr.config.SQLiteDialect + + # 文件上传配置 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + +# 百度OCR配置 +# 方式1:直接填写密钥(不推荐,容易泄露) +# 方式2:使用环境变量(推荐) +# 请在百度智能云控制台获取以下密钥信息 +# 1. 访问:https://console.bce.baidu.com/ +# 2. 搜索"表格文字识别OCR" +# 3. 创建应用获取密钥 +baidu: + ocr: + # 百度OCR应用ID + app-id: "7029216" + # 百度OCR API Key + api-key: "P7mro32NiHTZPovHOM8lHmLA" + # 百度OCR Secret Key + secret-key: "5VAag1QcBkV73qbOMhUiamwYbTyPKsN8" + +# 日志配置 +logging: + level: + com.scheduleocr: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/schedule-ocr-backend/多节次多周次解析说明.md b/schedule-ocr-backend/多节次多周次解析说明.md new file mode 100644 index 0000000..9c1f225 --- /dev/null +++ b/schedule-ocr-backend/多节次多周次解析说明.md @@ -0,0 +1,107 @@ +# 多节次多周次课程解析优化 + +## 🎯 解析目标 + +### 原始OCR数据: +``` +数据库原理与应用(A)[04]1-16周(第1-2节)曾广平 15号楼15307 +``` + +### 解析需求: +1. **课程名称**:数据库原理与应用(A) +2. **周次范围**:1-16周(表示整个学期都有这门课) +3. **节次范围**:第1-2节(表示占用两个连续时间段) +4. **教师**:曾广平 +5. **教室**:15号楼15307 + +## ✅ 优化后的解析逻辑 + +### 1. 节次范围解析 +- **输入**:`第1-2节` +- **解析**:创建两条课程记录 + - 第1节:08:00-08:45 + - 第2节:08:55-09:40 + +### 2. 周次范围解析 +- **输入**:`1-16周` +- **解析**:在备注中记录周次范围 +- **避免**:不为每个周次创建单独记录(避免数据冗余) + +### 3. 最终结果 +从一条OCR数据生成两条课程记录: + +**记录1:** +```json +{ + "courseName": "数据库原理与应用(A)", + "teacherName": "曾广平", + "classroom": "15号楼15307", + "dayOfWeek": 3, + "startTime": "08:00", + "endTime": "08:45", + "notes": "1-16周" +} +``` + +**记录2:** +```json +{ + "courseName": "数据库原理与应用(A)", + "teacherName": "曾广平", + "classroom": "15号楼15307", + "dayOfWeek": 3, + "startTime": "08:55", + "endTime": "09:40", + "notes": "1-16周" +} +``` + +## 🔧 核心方法 + +### `parsePeriodRange(String info)` +- 解析节次范围:`第1-2节` → `[1, 2]` +- 支持单节次:`第3节` → `[3]` + +### `parseWeekRange(String info)` +- 解析周次范围:`1-16周` → `[1, 2, ..., 16]` +- 用于生成备注信息 + +### `parseCourseInfo()` +- 返回类型改为 `List` +- 为每个节次创建独立的课程记录 +- 在备注中记录周次信息 + +## 📱 前端兼容性 + +### 现有前端逻辑: +- ✅ 按课程记录显示,天然支持多节次 +- ✅ 课程表网格会正确显示所有时间段的课程 +- ✅ 不需要修改前端代码 + +### 显示效果: +``` +星期三 +┌─────────────────┐ +│ 第1节 08:00-08:45 │ +│ 数据库原理与应用(A) │ +│ 曾广平 15号楼15307 │ +├─────────────────┤ +│ 第2节 08:55-09:40 │ +│ 数据库原理与应用(A) │ +│ 曾广平 15号楼15307 │ +└─────────────────┘ +``` + +## 🧪 测试验证 + +### 预期结果: +1. **OCR识别**:成功识别表格和课程信息 +2. **解析数量**:从原来的0门课程变为实际课程数量 +3. **节次处理**:连续节次的课程会创建多条记录 +4. **前端显示**:课程表正确显示所有时间段的课程 + +### 测试步骤: +1. 重启后端服务 +2. 使用小程序上传课程表图片 +3. 查看后端日志:应该显示解析出的课程数量 > 0 +4. 检查前端课程表:应该显示完整的课程安排 diff --git a/schedule-ocr-backend/百度OCR配置说明.md b/schedule-ocr-backend/百度OCR配置说明.md new file mode 100644 index 0000000..1d86f42 --- /dev/null +++ b/schedule-ocr-backend/百度OCR配置说明.md @@ -0,0 +1,112 @@ +# 百度OCR配置说明 + +## 📋 获取百度OCR密钥步骤 + +### 1. 注册百度智能云账号 +- 访问:https://cloud.baidu.com/ +- 注册并登录账号 + +### 2. 开通文字识别服务 +- 登录后访问:https://console.bce.baidu.com/ +- 搜索"文字识别OCR" +- 点击"立即使用" + +### 3. 创建应用 +- 在OCR控制台中点击"创建应用" +- 填写应用名称:`课表OCR识别` +- 选择应用类型:`通用文字识别` +- 创建成功后获取以下信息: + - **AppID**:应用ID + - **API Key**:API密钥 + - **Secret Key**:安全密钥 + +## 🔧 配置到项目 + +### 方法1:直接修改application.yml +```yaml +baidu: + ocr: + app-id: "你的AppID" + api-key: "你的API Key" + secret-key: "你的Secret Key" +``` + +### 方法2:使用环境变量(推荐) +```bash +# Windows +set BAIDU_OCR_APP_ID=你的AppID +set BAIDU_OCR_API_KEY=你的API Key +set BAIDU_OCR_SECRET_KEY=你的Secret Key + +# Linux/Mac +export BAIDU_OCR_APP_ID=你的AppID +export BAIDU_OCR_API_KEY=你的API Key +export BAIDU_OCR_SECRET_KEY=你的Secret Key +``` + +然后在application.yml中使用: +```yaml +baidu: + ocr: + app-id: ${BAIDU_OCR_APP_ID:YOUR_APP_ID} + api-key: ${BAIDU_OCR_API_KEY:YOUR_API_KEY} + secret-key: ${BAIDU_OCR_SECRET_KEY:YOUR_SECRET_KEY} +``` + +## 🧪 测试OCR配置 + +### 1. 配置测试 +```bash +curl http://localhost:8080/api/ocr-test/config +``` + +### 2. 简单功能测试 +```bash +curl -X POST "http://localhost:8080/api/ocr-test/simple?imageUrl=图片URL" +``` + +### 3. 完整功能测试 +使用Postman或其他工具上传图片到: +``` +POST http://localhost:8080/api/ocr/upload +``` + +## 💰 费用说明 + +百度OCR提供免费额度: +- **通用文字识别**:每月1000次免费 +- **高精度文字识别**:每月500次免费 +- 超出免费额度后按次计费 + +对于课表OCR小程序的使用量,免费额度通常足够。 + +## 🔍 常见问题 + +### Q1: 获取不到密钥? +- 确保已完成实名认证 +- 检查是否已开通文字识别服务 +- 确认应用创建成功 + +### Q2: API调用失败? +- 检查密钥是否正确配置 +- 确认网络连接正常 +- 查看错误码对应的具体原因 + +### Q3: 识别效果不好? +- 确保图片清晰度足够 +- 避免图片过大或过小 +- 可以尝试高精度识别接口 + +## 📞 技术支持 + +如遇到问题,可以: +1. 查看百度OCR官方文档:https://cloud.baidu.com/doc/OCR/ +2. 访问百度智能云技术论坛 +3. 联系百度智能云客服 + +## 🚀 下一步 + +配置完成后,您可以: +1. 重启应用 +2. 访问 http://localhost:8080/api/ocr-test/help 查看测试说明 +3. 开始开发微信小程序前端 diff --git a/schedule-ocr-miniprogram/miniprogram/app.json b/schedule-ocr-miniprogram/miniprogram/app.json new file mode 100644 index 0000000..4801ddd --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/app.json @@ -0,0 +1,40 @@ +{ + "pages": [ + "pages/index/index", + "pages/course-add/course-add", + "pages/ocr-import/ocr-import", + "pages/profile/profile" + ], + "window": { + "navigationBarTextStyle": "white", + "navigationBarTitleText": "课表助手", + "navigationBarBackgroundColor": "#1296db", + "backgroundColor": "#f8f8f8", + "backgroundTextStyle": "light" + }, + + "tabBar": { + "color": "#999999", + "selectedColor": "#1296db", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "images/tab-home.png", + "selectedIconPath": "images/tab-home-active.png" + }, + { + "pagePath": "pages/profile/profile", + "text": "个人中心", + "iconPath": "images/tab-profile.png", + "selectedIconPath": "images/tab-profile-active.png" + } + ] + }, + + "style": "v2", + "componentFramework": "glass-easel", + "lazyCodeLoading": "requiredComponents" +} \ No newline at end of file diff --git a/schedule-ocr-miniprogram/miniprogram/app.ts b/schedule-ocr-miniprogram/miniprogram/app.ts new file mode 100644 index 0000000..1af73a8 --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/app.ts @@ -0,0 +1,18 @@ +// app.ts +App({ + globalData: {}, + onLaunch() { + // 展示本地存储能力 + const logs = wx.getStorageSync('logs') || [] + logs.unshift(Date.now()) + wx.setStorageSync('logs', logs) + + // 登录 + wx.login({ + success: res => { + console.log(res.code) + // 发送 res.code 到后台换取 openId, sessionKey, unionId + }, + }) + }, +}) \ No newline at end of file diff --git a/schedule-ocr-miniprogram/miniprogram/app.wxss b/schedule-ocr-miniprogram/miniprogram/app.wxss new file mode 100644 index 0000000..b970e0d --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/app.wxss @@ -0,0 +1,61 @@ +/**app.wxss**/ + +/* 全局样式重置 */ +page { + height: 100%; + background-color: #f8f8f8; + font-family: -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif; +} + +/* 通用容器 */ +.container { + height: 100%; + display: flex; + flex-direction: column; +} + +/* 通用文本样式 */ +.text-primary { + color: #1296db; +} + +.text-secondary { + color: #666; +} + +.text-muted { + color: #999; +} + +.text-danger { + color: #ff4757; +} + +.text-success { + color: #2ed573; +} + +/* 通用布局 */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.flex-1 { + flex: 1; +} diff --git a/schedule-ocr-miniprogram/miniprogram/images/README.md b/schedule-ocr-miniprogram/miniprogram/images/README.md new file mode 100644 index 0000000..1db30cf --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/images/README.md @@ -0,0 +1,17 @@ +# 图标说明 + +这个目录用于存放tabBar图标,需要以下图标文件: + +## 必需的图标文件 +- tab-home.png (首页图标 - 未选中) +- tab-home-active.png (首页图标 - 选中) +- tab-profile.png (个人中心图标 - 未选中) +- tab-profile-active.png (个人中心图标 - 选中) + +## 图标规格 +- 尺寸:81px * 81px +- 格式:PNG +- 背景:透明 + +## 临时解决方案 +在开发阶段,可以暂时注释掉app.json中的tabBar配置,或使用微信开发者工具的默认图标。 diff --git a/schedule-ocr-miniprogram/miniprogram/images/camera_icon.png b/schedule-ocr-miniprogram/miniprogram/images/camera_icon.png new file mode 100644 index 0000000..84960d7 Binary files /dev/null and b/schedule-ocr-miniprogram/miniprogram/images/camera_icon.png differ diff --git a/schedule-ocr-miniprogram/miniprogram/images/logo.png b/schedule-ocr-miniprogram/miniprogram/images/logo.png new file mode 100644 index 0000000..5305a6a Binary files /dev/null and b/schedule-ocr-miniprogram/miniprogram/images/logo.png differ diff --git a/schedule-ocr-miniprogram/miniprogram/images/tab-home-active.png b/schedule-ocr-miniprogram/miniprogram/images/tab-home-active.png new file mode 100644 index 0000000..14492ba Binary files /dev/null and b/schedule-ocr-miniprogram/miniprogram/images/tab-home-active.png differ diff --git a/schedule-ocr-miniprogram/miniprogram/images/tab-home.png b/schedule-ocr-miniprogram/miniprogram/images/tab-home.png new file mode 100644 index 0000000..275b42f Binary files /dev/null and b/schedule-ocr-miniprogram/miniprogram/images/tab-home.png differ diff --git a/schedule-ocr-miniprogram/miniprogram/images/tab-profile-active.png b/schedule-ocr-miniprogram/miniprogram/images/tab-profile-active.png new file mode 100644 index 0000000..de523be Binary files /dev/null and b/schedule-ocr-miniprogram/miniprogram/images/tab-profile-active.png differ diff --git a/schedule-ocr-miniprogram/miniprogram/images/tab-profile.png b/schedule-ocr-miniprogram/miniprogram/images/tab-profile.png new file mode 100644 index 0000000..b0f1f70 Binary files /dev/null and b/schedule-ocr-miniprogram/miniprogram/images/tab-profile.png differ diff --git a/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json new file mode 100644 index 0000000..6ecee90 --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "添加课程", + "navigationBarBackgroundColor": "#1296db", + "navigationBarTextStyle": "white" +} diff --git a/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts new file mode 100644 index 0000000..9a42727 --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts @@ -0,0 +1,383 @@ +// course-add.ts + +// 课程接口定义 +interface Course { + id?: number; + courseName: string; + classroom: string; + teacherName?: string; + dayOfWeek: number; + startTime: string; + endTime: string; + notes?: string; + userOpenid?: string; + weeks?: string; // 周次信息,如 "1-16周" + timeSlot?: number; // 对应第几节课 (1-11) + startWeek?: number; // 开始周次 + endWeek?: number; // 结束周次 + weekType?: number; // 单双周标识(0=每周,1=单周,2=双周) +} + +Page({ + data: { + mode: 'add', // 'add' 或 'edit' + courseId: 0, + weekOptions: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], + weekTypeOptions: [ + { name: '每周', value: 0 }, + { name: '单周', value: 1 }, + { name: '双周', value: 2 } + ], + timeSlots: [ + { name: '第1节', time: '08:00-08:45', value: 1 }, + { name: '第2节', time: '08:55-09:40', value: 2 }, + { name: '第3节', time: '10:00-10:45', value: 3 }, + { name: '第4节', time: '10:55-11:40', value: 4 }, + { name: '第5节', time: '14:10-14:55', value: 5 }, + { name: '第6节', time: '15:05-15:50', value: 6 }, + { name: '第7节', time: '16:00-16:45', value: 7 }, + { name: '第8节', time: '16:55-17:40', value: 8 }, + { name: '第9节', time: '18:40-19:25', value: 9 }, + { name: '第10节', time: '19:30-20:15', value: 10 }, + { name: '第11节', time: '20:20-21:05', value: 11 } + ], + formData: { + courseName: '', + classroom: '', + teacherName: '', + dayOfWeek: 0, + startTime: '', + endTime: '', + notes: '', + weeks: '', + timeSlot: 0, + startWeek: 1, + endWeek: 16, + weekType: 0 + } as Course, + canSave: false, + loading: false + }, + + onLoad(options: any) { + const mode = options.mode || 'add'; + const courseId = parseInt(options.id) || 0; + + this.setData({ + mode, + courseId + }); + + if (mode === 'edit' && courseId) { + this.loadCourseData(courseId); + } else { + // 新增模式下初始化周次文本 + this.updateWeeksText(); + } + + // 设置导航栏标题 + wx.setNavigationBarTitle({ + title: mode === 'edit' ? '编辑课程' : '添加课程' + }); + }, + + // 加载课程数据(编辑模式) + async loadCourseData(courseId: number) { + this.setData({ loading: true }); + + try { + const api = require('../../utils/api').default; + const res = await api.course.getCourse(courseId); + + if (res.code === 200) { + const courseData = res.data; + + // 确保周次字段有默认值 + if (!courseData.startWeek) courseData.startWeek = 1; + if (!courseData.endWeek) courseData.endWeek = 16; + if (courseData.weekType === undefined) courseData.weekType = 0; + + // 确保数据类型正确 + courseData.dayOfWeek = parseInt(courseData.dayOfWeek) || 0; + + // 根据startTime和endTime确定timeSlot + courseData.timeSlot = this.getTimeSlotByTime(courseData.startTime, courseData.endTime); + + this.setData({ + formData: courseData + }); + + // 更新周次文本显示 + this.updateWeeksText(); + this.validateForm(); + } else { + api.handleError(res.message, '加载课程失败'); + } + } catch (error) { + const api = require('../../utils/api').default; + api.handleError(error, '网络错误'); + } finally { + this.setData({ loading: false }); + } + }, + + // 根据开始时间和结束时间获取对应的时间段 + getTimeSlotByTime(startTime: string, endTime: string): number { + if (!startTime || !endTime) return 0; + + const timeRange = `${startTime}-${endTime}`; + const timeSlot = this.data.timeSlots.find(slot => slot.time === timeRange); + return timeSlot ? timeSlot.value : 0; + }, + + // 课程名称输入 + onCourseNameInput(e: any) { + this.setData({ + 'formData.courseName': e.detail.value + }); + this.validateForm(); + }, + + // 上课地点输入 + onClassroomInput(e: any) { + this.setData({ + 'formData.classroom': e.detail.value + }); + }, + + // 任课教师输入 + onTeacherNameInput(e: any) { + this.setData({ + 'formData.teacherName': e.detail.value + }); + }, + + // 星期选择 + onWeekSelect(e: any) { + const day = e.currentTarget.dataset.day; + this.setData({ + 'formData.dayOfWeek': day + }); + this.validateForm(); + }, + + // 时间段选择 + onTimeSlotSelect(e: any) { + const timeSlotIndex = e.currentTarget.dataset.index; + const timeSlot = this.data.timeSlots[timeSlotIndex]; + + if (timeSlot) { + const [startTime, endTime] = timeSlot.time.split('-'); + this.setData({ + 'formData.timeSlot': timeSlot.value, + 'formData.startTime': startTime, + 'formData.endTime': endTime + }); + this.validateForm(); + } + }, + + // 周次输入 + onWeeksInput(e: any) { + const weeksText = e.detail.value; + this.setData({ + 'formData.weeks': weeksText + }); + + // 解析周次信息 + this.parseWeeksInfo(weeksText); + }, + + // 解析周次信息 + parseWeeksInfo(weeksText: string) { + if (!weeksText) { + this.setData({ + 'formData.startWeek': 1, + 'formData.endWeek': 16, + 'formData.weekType': 0 + }); + return; + } + + // 匹配格式:1-16周、1-16周(单)、1-16周(双)等 + const weekPattern = /(\d+)-(\d+)周?\s*(\(([单双])\))?/; + const match = weeksText.match(weekPattern); + + if (match) { + const startWeek = parseInt(match[1]); + const endWeek = parseInt(match[2]); + const weekTypeText = match[4]; + + let weekType = 0; // 默认每周 + if (weekTypeText === '单') { + weekType = 1; + } else if (weekTypeText === '双') { + weekType = 2; + } + + this.setData({ + 'formData.startWeek': startWeek, + 'formData.endWeek': endWeek, + 'formData.weekType': weekType + }); + } + }, + + // 开始周次输入 + onStartWeekInput(e: any) { + const startWeek = parseInt(e.detail.value) || 1; + this.setData({ + 'formData.startWeek': startWeek + }); + this.updateWeeksText(); + }, + + // 结束周次输入 + onEndWeekInput(e: any) { + const endWeek = parseInt(e.detail.value) || 16; + this.setData({ + 'formData.endWeek': endWeek + }); + this.updateWeeksText(); + }, + + // 单双周选择 + onWeekTypeSelect(e: any) { + const weekType = e.currentTarget.dataset.type; + this.setData({ + 'formData.weekType': weekType + }); + this.updateWeeksText(); + }, + + // 更新周次文本显示 + updateWeeksText() { + const { startWeek, endWeek, weekType } = this.data.formData; + let weeksText = `${startWeek}-${endWeek}周`; + + if (weekType === 1) { + weeksText += '(单)'; + } else if (weekType === 2) { + weeksText += '(双)'; + } + + this.setData({ + 'formData.weeks': weeksText + }); + }, + + // 备注输入 + onNotesInput(e: any) { + this.setData({ + 'formData.notes': e.detail.value + }); + }, + + // 表单验证 + validateForm() { + const { courseName, dayOfWeek, timeSlot } = this.data.formData; + const canSave = !!(courseName && dayOfWeek && timeSlot); + this.setData({ canSave }); + }, + + // 保存课程 + async onSave() { + if (!this.data.canSave) { + wx.showToast({ + title: '请填写必填项', + icon: 'none' + }); + return; + } + + // 验证时间段是否选择 + if (!this.data.formData.timeSlot) { + wx.showToast({ + title: '请选择上课时间段', + icon: 'none' + }); + return; + } + + this.setData({ loading: true }); + + try { + const api = require('../../utils/api').default; + const courseData = { + ...this.data.formData, + userOpenid: api.getUserOpenid() + }; + + let res; + if (this.data.mode === 'edit') { + res = await api.course.updateCourse(this.data.courseId, courseData); + } else { + res = await api.course.createCourse(courseData); + } + + if (res.code === 200) { + wx.showToast({ + title: this.data.mode === 'edit' ? '保存成功' : '添加成功', + icon: 'success' + }); + + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } else { + api.handleError(res.message, '操作失败'); + } + } catch (error) { + const api = require('../../utils/api').default; + api.handleError(error, '网络错误'); + } finally { + this.setData({ loading: false }); + } + }, + + // 取消 + onCancel() { + wx.navigateBack(); + }, + + // 删除课程 + onDelete() { + wx.showModal({ + title: '确认删除', + content: '确定要删除这门课程吗?', + success: (res) => { + if (res.confirm) { + this.deleteCourse(); + } + } + }); + }, + + // 执行删除 + async deleteCourse() { + this.setData({ loading: true }); + + try { + const api = require('../../utils/api').default; + const res = await api.course.deleteCourse(this.data.courseId); + + if (res.code === 200) { + wx.showToast({ + title: '删除成功', + icon: 'success' + }); + + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } else { + api.handleError(res.message, '删除失败'); + } + } catch (error) { + const api = require('../../utils/api').default; + api.handleError(error, '网络错误'); + } finally { + this.setData({ loading: false }); + } + } +}) diff --git a/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml new file mode 100644 index 0000000..10ca3cc --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml @@ -0,0 +1,143 @@ + + + + + {{mode === 'edit' ? '编辑课程' : '添加课程'}} + + + + + + + + 课程名称 * + + + + + + 上课地点 + + + + + + 任课教师 + + + + + + 星期几 * + + + {{item}} + + + + + + + 上课时间 * + + + {{item.name}} + {{item.time}} + + + + + + + 周次 + + + + + + + + + + + + + + + + + + + + {{item.name}} + + + + + + 预览: + {{formData.weeks || '1-16周'}} + + + + + + + 备注 +