初始提交:课表OCR识别小程序完整项目

- 前端:微信小程序,支持OCR识别和课程管理
- 后端:Spring Boot,提供API服务和数据管理
- 功能:表格OCR识别、时间冲突检测、周次管理
- 文档:完整的使用教程和技术说明
master
Schedule OCR Team 4 weeks ago
commit 0139983fc3

78
.gitignore vendored

@ -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

@ -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月

@ -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. 验证课程是否正确导入到数据库

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.scheduleocr</groupId>
<artifactId>schedule-ocr-backend</artifactId>
<version>1.0.0</version>
<name>schedule-ocr-backend</name>
<description>大学生课表OCR识别小程序后端服务</description>
<properties>
<java.version>8</java.version>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- SQLite JDBC Driver -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.42.0.0</version>
</dependency>
<!-- 百度OCR SDK -->
<dependency>
<groupId>com.baidu.aip</groupId>
<artifactId>java-sdk</artifactId>
<version>4.16.8</version>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 文件上传处理 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version>
</dependency>
<!-- Apache Commons IO -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

@ -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("=================================");
}
}

@ -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 {
/**
* OCRID
*/
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;
}
/**
* OCRBean
*
* @return AipOcr
*/
@Bean
public AipOcr aipOcr() {
// 初始化一个AipOcr
AipOcr client = new AipOcr(appId, apiKey, secretKey);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
return client;
}
}

@ -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;
}
}

@ -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仓库和事务管理
}

@ -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("测试数据插入完成");
}
}

@ -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.7Hibernate
*
* @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;
}
}
}

@ -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;
/**
*
* CRUDAPI
*
* @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<List<Course>> getCourses(
@RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid,
@RequestParam(required = false) Integer dayOfWeek,
@RequestParam(required = false) Integer currentWeek) {
try {
List<Course> 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<Course> getCourse(@PathVariable Long id) {
try {
Optional<Course> 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<Course> 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<Course> 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<Void> 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<Void> 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<Long> 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());
}
}
}

@ -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<Map<String, Object>> health() {
Map<String, Object> 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<Map<String, Object>> info() {
Map<String, Object> 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<String, Object> 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);
}
}

@ -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<OcrResult> 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<ImportResult> importCourses(
@RequestBody @Valid ImportRequest request) {
try {
log.info("开始导入课程,用户: {}, 课程数量: {}",
request.getUserOpenid(), request.getCourses().size());
List<Course> successCourses = new ArrayList<>();
List<String> 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<CourseDTO> courses;
// Getters and Setters
public String getUserOpenid() {
return userOpenid;
}
public void setUserOpenid(String userOpenid) {
this.userOpenid = userOpenid;
}
public List<CourseDTO> getCourses() {
return courses;
}
public void setCourses(List<CourseDTO> courses) {
this.courses = courses;
}
}
/**
* DTO
*/
public static class ImportResult {
private int totalCount;
private int successCount;
private int failCount;
private List<Course> successCourses;
private List<String> 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<Course> getSuccessCourses() {
return successCourses;
}
public void setSuccessCourses(List<Course> successCourses) {
this.successCourses = successCourses;
}
public List<String> getErrorMessages() {
return errorMessages;
}
public void setErrorMessages(List<String> errorMessages) {
this.errorMessages = errorMessages;
}
}
}

@ -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<Map<String, Object>> testOcrConfig() {
try {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> simpleOcrTest(
@RequestParam(required = false) String imageUrl) {
try {
Map<String, Object> result = new HashMap<>();
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
// 测试网络图片识别
HashMap<String, String> 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<String, Object> 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<Map<String, Object>> getOcrHelp() {
Map<String, Object> 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<String, String> 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);
}
}

@ -0,0 +1,104 @@
package com.scheduleocr.dto;
/**
* API
*
* @author scheduleocr
* @version 1.0.0
*/
public class ApiResponse<T> {
/**
*
* 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 <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "操作成功", data);
}
/**
*
*/
public static <T> ApiResponse<T> success() {
return new ApiResponse<>(200, "操作成功", null);
}
/**
*
*/
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(200, message, data);
}
/**
*
*/
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(400, message, null);
}
/**
*
*/
public static <T> ApiResponse<T> error(Integer code, String message) {
return new ApiResponse<>(code, message, null);
}
/**
*
*/
public static <T> ApiResponse<T> serverError(String message) {
return new ApiResponse<>(500, message, null);
}
}

@ -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;
}
}

@ -0,0 +1,111 @@
package com.scheduleocr.dto;
import java.util.List;
/**
* OCRDTO
*
* @author scheduleocr
* @version 1.0.0
*/
public class OcrResult {
/**
*
*/
private boolean success;
/**
*
*/
private String errorMessage;
/**
*
*/
private String rawText;
/**
*
*/
private List<CourseDTO> courses;
/**
*
*/
private List<String> textLines;
// Constructors
public OcrResult() {}
public OcrResult(boolean success, String errorMessage, String rawText,
List<CourseDTO> courses, List<String> 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<CourseDTO> getCourses() {
return courses;
}
public void setCourses(List<CourseDTO> courses) {
this.courses = courses;
}
public List<String> getTextLines() {
return textLines;
}
public void setTextLines(List<String> textLines) {
this.textLines = textLines;
}
/**
*
*/
public static OcrResult success(String rawText, List<String> textLines, List<CourseDTO> 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;
}
}

@ -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, 17)
*/
@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;
}
}

@ -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<Void> handleValidationException(MethodArgumentNotValidException e) {
log.warn("参数校验失败", e);
List<String> 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<Void> handleBindException(BindException e) {
log.warn("参数绑定失败", e);
List<String> 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<Void> handleConstraintViolationException(ConstraintViolationException e) {
log.warn("约束校验失败", e);
List<String> 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<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
log.warn("文件上传大小超限", e);
return ApiResponse.error(400, "上传文件大小超过限制请选择小于10MB的文件");
}
/**
*
*/
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleRuntimeException(RuntimeException e) {
log.warn("业务异常", e);
return ApiResponse.error(e.getMessage());
}
/**
*
*/
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleIllegalArgumentException(IllegalArgumentException e) {
log.warn("非法参数异常", e);
return ApiResponse.error("参数错误: " + e.getMessage());
}
/**
*
*/
@ExceptionHandler(NullPointerException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleNullPointerException(NullPointerException e) {
log.error("空指针异常", e);
return ApiResponse.serverError("系统内部错误,请稍后重试");
}
/**
*
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleException(Exception e) {
log.error("系统异常", e);
return ApiResponse.serverError("系统内部错误: " + e.getMessage());
}
}

@ -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<Course, Long> {
/**
* openid
*
* @param userOpenid openid
* @return
*/
List<Course> findByUserOpenidOrderByDayOfWeekAscStartTimeAsc(String userOpenid);
/**
* openid
*
* @param userOpenid openid
* @param dayOfWeek
* @return
*/
List<Course> 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<Course> findPotentialConflictCourses(@Param("userOpenid") String userOpenid,
@Param("dayOfWeek") Integer dayOfWeek,
@Param("excludeId") Long excludeId);
/**
*
*
* @param userOpenid openid
* @return
*/
long countByUserOpenid(String userOpenid);
}

@ -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<Course> getAllCourses(String userOpenid) {
log.info("获取用户课程列表userOpenid: {}", userOpenid);
return courseRepository.findByUserOpenidOrderByDayOfWeekAscStartTimeAsc(userOpenid);
}
/**
*
*
* @param userOpenid openid
* @param dayOfWeek
* @return
*/
public List<Course> getCoursesByDay(String userOpenid, Integer dayOfWeek) {
log.info("获取用户指定日期课程userOpenid: {}, dayOfWeek: {}", userOpenid, dayOfWeek);
return courseRepository.findByUserOpenidAndDayOfWeekOrderByStartTimeAsc(userOpenid, dayOfWeek);
}
/**
* ID
*
* @param id ID
* @return
*/
public Optional<Course> 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<Course> potentialConflicts = courseRepository.findPotentialConflictCourses(
courseDTO.getUserOpenid(),
courseDTO.getDayOfWeek(),
null
);
// 在Java中进行精确的时间和周次冲突检测
List<Course> 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<Course> existingCourse = courseRepository.findById(id);
if (!existingCourse.isPresent()) {
throw new RuntimeException("课程不存在");
}
// 检查时间冲突(排除自己)
List<Course> potentialConflicts = courseRepository.findPotentialConflictCourses(
courseDTO.getUserOpenid(),
courseDTO.getDayOfWeek(),
id
);
// 在Java中进行精确的时间和周次冲突检测
List<Course> 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<Course> filterCoursesByWeek(List<Course> 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;
}
}

@ -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<String, String> 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<String> 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<CourseDTO> 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;
}
}
/**
* OCRV2 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<String, String> 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<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
// 发送请求
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> 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;
}
}
/**
* APIaccess_token
*/
private String getAccessToken() throws Exception {
String url = "https://aip.baidubce.com/oauth/2.0/token";
MultiValueMap<String, String> 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<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> 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<CourseDTO> parseCoursesFromTableResult(JSONObject response, String userOpenid) {
List<CourseDTO> 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<String, Map<String, String>> 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<CourseDTO> extractCoursesFromTableGrid(Map<String, Map<String, String>> tableGrid, String userOpenid) {
List<CourseDTO> courses = new ArrayList<>();
try {
// 星期映射
Map<String, Integer> 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<String, String> headerRow = tableGrid.get("row_0");
if (headerRow == null) {
log.warn("找不到表头行");
return courses;
}
// 建立列索引到星期的映射
Map<Integer, Integer> colToDayMapping = new HashMap<>();
for (Map.Entry<String, String> 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<String, String> 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<Integer, Integer> 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<CourseDTO> 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<CourseDTO> parseCourseInfo(String courseInfo, String userOpenid, int dayOfWeek, int startPeriod, String timeRange) {
List<CourseDTO> 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<Integer> periods = parsePeriodRange(weekInfo);
log.debug("解析到的节次: {}", periods);
if (periods.isEmpty()) {
periods.add(startPeriod); // 默认使用传入的节次
}
// 解析周次信息(如"1-16周"
List<Integer> 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<Integer> parsePeriodRange(String info) {
List<Integer> 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<Integer> parseWeekRange(String info) {
List<Integer> 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+)周"), // "116周"
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;
}
}

@ -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"

@ -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"

@ -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<CourseDTO>`
- 为每个节次创建独立的课程记录
- 在备注中记录周次信息
## 📱 前端兼容性
### 现有前端逻辑:
- ✅ 按课程记录显示,天然支持多节次
- ✅ 课程表网格会正确显示所有时间段的课程
- ✅ 不需要修改前端代码
### 显示效果:
```
星期三
┌─────────────────┐
│ 第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. 检查前端课程表:应该显示完整的课程安排

@ -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. 开始开发微信小程序前端

@ -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"
}

@ -0,0 +1,18 @@
// app.ts
App<IAppOption>({
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
},
})
},
})

@ -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;
}

@ -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配置或使用微信开发者工具的默认图标。

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@ -0,0 +1,5 @@
{
"navigationBarTitleText": "添加课程",
"navigationBarBackgroundColor": "#1296db",
"navigationBarTextStyle": "white"
}

@ -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 });
}
}
})

@ -0,0 +1,143 @@
<!--course-add.wxml-->
<view class="container">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title">{{mode === 'edit' ? '编辑课程' : '添加课程'}}</text>
</view>
<!-- 表单内容 -->
<scroll-view class="form-content" scroll-y="true">
<view class="form-section">
<!-- 课程名称 -->
<view class="form-item">
<text class="label">课程名称 *</text>
<input class="input"
placeholder="请输入课程名称"
value="{{formData.courseName}}"
bindinput="onCourseNameInput" />
</view>
<!-- 上课地点 -->
<view class="form-item">
<text class="label">上课地点</text>
<input class="input"
placeholder="请输入上课地点"
value="{{formData.classroom}}"
bindinput="onClassroomInput" />
</view>
<!-- 任课教师 -->
<view class="form-item">
<text class="label">任课教师</text>
<input class="input"
placeholder="请输入任课教师"
value="{{formData.teacherName}}"
bindinput="onTeacherNameInput" />
</view>
<!-- 星期几 -->
<view class="form-item">
<text class="label">星期几 *</text>
<view class="week-selector">
<view class="week-option {{formData.dayOfWeek === index + 1 ? 'selected' : ''}}"
wx:for="{{weekOptions}}"
wx:key="index"
bindtap="onWeekSelect"
data-day="{{index + 1}}">
<text class="week-text">{{item}}</text>
</view>
</view>
</view>
<!-- 上课时间 -->
<view class="form-item">
<text class="label">上课时间 *</text>
<view class="time-slot-selector">
<view class="time-slot-option {{formData.timeSlot === item.value ? 'selected' : ''}}"
wx:for="{{timeSlots}}"
wx:key="value"
bindtap="onTimeSlotSelect"
data-index="{{index}}">
<text class="time-slot-name">{{item.name}}</text>
<text class="time-slot-time">{{item.time}}</text>
</view>
</view>
</view>
<!-- 周次 -->
<view class="form-item">
<text class="label">周次</text>
<view class="week-range-container">
<!-- 周次范围输入 -->
<view class="week-range-inputs">
<view class="week-input-group">
<text class="week-label">第</text>
<input class="week-input"
type="number"
value="{{formData.startWeek}}"
bindinput="onStartWeekInput"
placeholder="1" />
<text class="week-label">周</text>
</view>
<text class="week-separator">至</text>
<view class="week-input-group">
<text class="week-label">第</text>
<input class="week-input"
type="number"
value="{{formData.endWeek}}"
bindinput="onEndWeekInput"
placeholder="16" />
<text class="week-label">周</text>
</view>
</view>
<!-- 单双周选择 -->
<view class="week-type-selector">
<view class="week-type-option {{formData.weekType === item.value ? 'selected' : ''}}"
wx:for="{{weekTypeOptions}}"
wx:key="value"
bindtap="onWeekTypeSelect"
data-type="{{item.value}}">
<text class="week-type-text">{{item.name}}</text>
</view>
</view>
<!-- 周次预览 -->
<view class="week-preview">
<text class="preview-label">预览:</text>
<text class="preview-text">{{formData.weeks || '1-16周'}}</text>
</view>
</view>
</view>
<!-- 备注 -->
<view class="form-item">
<text class="label">备注</text>
<textarea class="textarea"
placeholder="请输入备注信息"
value="{{formData.notes}}"
bindinput="onNotesInput"
maxlength="200" />
</view>
</view>
</scroll-view>
<!-- 底部操作按钮 -->
<view class="bottom-actions">
<button class="action-btn secondary" bindtap="onCancel">
<text class="btn-text">取消</text>
</button>
<button class="action-btn primary"
bindtap="onSave"
disabled="{{!canSave}}">
<text class="btn-text">{{mode === 'edit' ? '保存' : '添加'}}</text>
</button>
</view>
<!-- 删除按钮(仅编辑模式显示) -->
<view wx:if="{{mode === 'edit'}}" class="delete-section">
<button class="delete-btn" bindtap="onDelete">
<text class="btn-text">删除课程</text>
</button>
</view>
</view>

@ -0,0 +1,313 @@
/**course-add.wxss**/
.container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f8f8;
}
/* 页面标题 */
.page-header {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background-color: #1296db;
color: white;
}
.page-title {
font-size: 36rpx;
font-weight: bold;
}
/* 表单内容 */
.form-content {
flex: 1;
padding: 20rpx;
}
.form-section {
background-color: white;
border-radius: 16rpx;
padding: 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1);
}
.form-item {
margin-bottom: 40rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.label {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 16rpx;
font-weight: 500;
}
.input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
font-size: 28rpx;
background-color: #fafafa;
box-sizing: border-box;
}
.input:focus {
border-color: #1296db;
background-color: white;
}
.textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
font-size: 28rpx;
background-color: #fafafa;
box-sizing: border-box;
}
.textarea:focus {
border-color: #1296db;
background-color: white;
}
/* 星期选择器 */
.week-selector {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.week-option {
flex: 1;
min-width: 80rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
background-color: #fafafa;
transition: all 0.3s;
}
.week-option.selected {
background-color: #1296db;
border-color: #1296db;
}
.week-text {
font-size: 26rpx;
color: #666;
}
.week-option.selected .week-text {
color: white;
}
/* 时间段选择器 */
.time-slot-selector {
display: flex;
flex-direction: column;
gap: 12rpx;
max-height: 400rpx;
overflow-y: auto;
}
.time-slot-option {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
background-color: #fafafa;
transition: all 0.3s;
}
.time-slot-option.selected {
background-color: #1296db;
border-color: #1296db;
}
.time-slot-name {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.time-slot-time {
font-size: 24rpx;
color: #666;
}
.time-slot-option.selected .time-slot-name,
.time-slot-option.selected .time-slot-time {
color: white;
}
/* 周次选择器 */
.week-range-container {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.week-range-inputs {
display: flex;
align-items: center;
gap: 20rpx;
}
.week-input-group {
display: flex;
align-items: center;
gap: 8rpx;
}
.week-label {
font-size: 26rpx;
color: #666;
}
.week-input {
width: 80rpx;
height: 60rpx;
text-align: center;
border: 1rpx solid #e5e5e5;
border-radius: 6rpx;
font-size: 26rpx;
background-color: #fafafa;
}
.week-input:focus {
border-color: #1296db;
background-color: white;
}
.week-separator {
font-size: 26rpx;
color: #666;
}
.week-type-selector {
display: flex;
gap: 12rpx;
}
.week-type-option {
flex: 1;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
background-color: #fafafa;
transition: all 0.3s;
}
.week-type-option.selected {
background-color: #1296db;
border-color: #1296db;
}
.week-type-text {
font-size: 26rpx;
color: #666;
}
.week-type-option.selected .week-type-text {
color: white;
}
.week-preview {
display: flex;
align-items: center;
gap: 8rpx;
padding: 16rpx 20rpx;
background-color: #f0f8ff;
border-radius: 8rpx;
border: 1rpx solid #e6f3ff;
}
.preview-label {
font-size: 24rpx;
color: #666;
}
.preview-text {
font-size: 26rpx;
color: #1296db;
font-weight: 500;
}
/* 底部操作按钮 */
.bottom-actions {
display: flex;
padding: 20rpx 30rpx;
background-color: white;
border-top: 1rpx solid #e5e5e5;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
border: none;
}
.action-btn.primary {
background: linear-gradient(135deg, #1296db 0%, #0d7ec7 100%);
color: white;
}
.action-btn.primary[disabled] {
background: #ccc;
color: #999;
}
.action-btn.secondary {
background-color: #f8f8f8;
color: #1296db;
border: 1rpx solid #1296db;
}
.btn-text {
font-size: 30rpx;
}
/* 删除按钮 */
.delete-section {
padding: 20rpx 30rpx;
background-color: white;
}
.delete-btn {
width: 100%;
height: 80rpx;
border-radius: 40rpx;
background-color: #ff4757;
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
}

@ -0,0 +1,427 @@
// index.ts
// 获取应用实例
const app = getApp<IAppOption>()
// 课程接口定义
interface Course {
id: number;
courseName: string;
classroom: string;
teacherName?: string;
dayOfWeek: number;
startTime: string;
endTime: string;
notes?: string;
weeks?: string; // 周次信息,如 "1-16周"
timeSlot?: number; // 对应第几节课 (1-11)
}
Page({
data: {
currentWeek: 1,
weekDays: [
{ name: '周一', date: '09/02' },
{ name: '周二', date: '09/03' },
{ name: '周三', date: '09/04' },
{ name: '周四', date: '09/05' },
{ name: '周五', date: '09/06' },
{ name: '周六', date: '09/07' },
{ name: '周日', date: '09/08' }
],
timeSlots: [
{ name: '第1节', time: '08:00-08:45' },
{ name: '第2节', time: '08:55-09:40' },
{ name: '第3节', time: '10:00-10:45' },
{ name: '第4节', time: '10:55-11:40' },
{ name: '第5节', time: '14:10-14:55' },
{ name: '第6节', time: '15:05-15:50' },
{ name: '第7节', time: '16:00-16:45' },
{ name: '第8节', time: '16:55-17:40' },
{ name: '第9节', time: '18:40-19:25' },
{ name: '第10节', time: '19:30-20:15' },
{ name: '第11节', time: '20:20-21:05' }
],
courses: [] as Course[],
scheduleGrid: [] as any[], // 用于模板渲染的课程表网格数据
loading: false,
showCourseDetail: false,
selectedCourse: {} as Course
},
onLoad() {
this.initCurrentWeek();
this.updateWeekDays();
this.loadCourses();
},
onShow() {
// 页面显示时重新加载课程数据
this.loadCourses();
},
// 根据当前选中的周次更新星期日期
updateWeekDays() {
const util = require('../../utils/util');
const semesterConfig = util.getSemesterConfig();
const currentWeek = this.data.currentWeek;
// 计算学期开始日期
const semesterStart = new Date(semesterConfig.startDate);
// 计算当前选中周的周一日期
const selectedWeekMonday = new Date(semesterStart);
selectedWeekMonday.setDate(semesterStart.getDate() + (currentWeek - 1) * 7);
// 确保是周一
const dayOfWeek = selectedWeekMonday.getDay();
if (dayOfWeek !== 1) { // 如果不是周一,调整到周一
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
selectedWeekMonday.setDate(selectedWeekMonday.getDate() + daysToMonday);
}
const weekDays = [];
const weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
for (let i = 0; i < 7; i++) {
const date = new Date(selectedWeekMonday);
date.setDate(selectedWeekMonday.getDate() + i);
weekDays.push({
name: weekNames[i],
date: `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
});
}
this.setData({ weekDays });
},
// 初始化当前周次
initCurrentWeek() {
// 导入工具函数
const util = require('../../utils/util');
const currentWeek = util.getCurrentWeek();
const semesterConfig = util.getSemesterConfig();
this.setData({ currentWeek });
console.log('当前周次:', currentWeek);
console.log('学期配置:', semesterConfig);
},
// 加载课程数据
async loadCourses() {
this.setData({ loading: true });
try {
// 导入API工具
const api = require('../../utils/api').default;
const userOpenid = api.getUserOpenid();
console.log('正在加载课程数据用户ID:', userOpenid);
const currentWeek = this.data.currentWeek;
const res = await api.course.getCourses(userOpenid, null, currentWeek);
console.log('API响应:', res);
if (res.code === 200) {
const courses = res.data || [];
console.log('获取到的课程数据:', courses);
this.setData({
courses: courses
});
// 生成课程表网格数据
this.generateScheduleGrid();
} else {
console.error('API返回错误:', res.message);
api.handleError(res.message, '加载课程失败');
}
} catch (error) {
console.error('网络请求失败:', error);
// 开发阶段使用模拟数据
const mockCourses = [
{
id: 1,
courseName: '数据库原理与应用',
classroom: '15号楼 15220',
teacherName: '李老师',
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
timeSlot: 1,
weeks: '1-16周',
notes: ''
},
{
id: 2,
courseName: '计算机网络',
classroom: '11号楼 11413',
teacherName: '张老师',
dayOfWeek: 2,
startTime: '08:55',
endTime: '09:40',
timeSlot: 2,
weeks: '1-16周',
notes: ''
},
{
id: 3,
courseName: 'UML与软件工程',
classroom: '9号楼 S090204',
teacherName: '王老师',
dayOfWeek: 5,
startTime: '08:00',
endTime: '08:45',
timeSlot: 1,
weeks: '1-2周',
notes: ''
},
{
id: 4,
courseName: '软件体系结构',
classroom: '11号楼 11202',
teacherName: '陈老师',
dayOfWeek: 4,
startTime: '10:55',
endTime: '11:40',
timeSlot: 4,
weeks: '3-4周',
notes: ''
}
];
this.setData({
courses: mockCourses
});
// 生成课程表网格数据
this.generateScheduleGrid();
} finally {
this.setData({ loading: false });
}
},
// 生成课程表网格数据
generateScheduleGrid() {
const { timeSlots, courses } = this.data;
console.log('开始生成课程表网格,时间段:', timeSlots);
console.log('课程数据:', courses);
const scheduleGrid = [];
// 为每个时间段生成一行数据
for (let timeIndex = 0; timeIndex < timeSlots.length; timeIndex++) {
const timeSlot = timeSlots[timeIndex];
const row = {
timeSlotName: timeSlot.name,
timeSlotTime: timeSlot.time,
courses: []
};
// 为每一天生成课程数据
for (let dayOfWeek = 1; dayOfWeek <= 7; dayOfWeek++) {
// 查找匹配的课程:根据星期和时间段匹配
const course = courses.find(c => {
const courseDayOfWeek = typeof c.dayOfWeek === 'string' ? parseInt(c.dayOfWeek) : c.dayOfWeek;
const dayMatch = courseDayOfWeek === dayOfWeek;
if (!dayMatch) return false;
// 如果课程有timeSlot字段直接匹配
if (c.timeSlot) {
return c.timeSlot === (timeIndex + 1);
}
// 根据开始时间匹配(每个节次都有独立记录)
const timeSlotStartTime = timeSlot.time.split('-')[0]; // "08:00-08:45" -> "08:00"
const timeMatch = c.startTime === timeSlotStartTime;
return timeMatch;
});
row.courses.push(course || null);
}
scheduleGrid.push(row);
}
console.log('生成的课程表网格数据:', scheduleGrid);
this.setData({
scheduleGrid: scheduleGrid
}, () => {
console.log('setData 完成,当前 scheduleGrid:', this.data.scheduleGrid);
});
},
// 时间转换为分钟数(用于时间比较)
timeToMinutes(timeStr: string): number {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
},
// 获取指定时间段的课程
getCourseForSlot(dayOfWeek: number, timeIndex: number): Course | null {
const timeSlot = this.data.timeSlots[timeIndex];
const [startTime] = timeSlot.time.split('-');
return this.data.courses.find(course => {
const courseDayOfWeek = typeof course.dayOfWeek === 'string' ? parseInt(course.dayOfWeek) : course.dayOfWeek;
if (courseDayOfWeek !== dayOfWeek) return false;
// 检查课程时间是否覆盖当前时间段
const currentStartTime = this.timeToMinutes(startTime);
const courseStartTime = this.timeToMinutes(course.startTime);
const courseEndTime = this.timeToMinutes(course.endTime);
return courseStartTime <= currentStartTime && courseEndTime > currentStartTime;
}) || null;
},
// 课程项点击事件
onCourseItemTap(e: any) {
const course = e.currentTarget.dataset.course;
wx.navigateTo({
url: `/pages/course-add/course-add?id=${course.id}&mode=edit`
});
},
// 课程项长按事件
onCourseItemLongPress(e: any) {
const course = e.currentTarget.dataset.course;
// 格式化周次信息
const formattedCourse = {
...course,
formattedWeeks: this.formatWeeksInfo(course)
};
this.setData({
selectedCourse: formattedCourse,
showCourseDetail: true
});
// 触觉反馈
wx.vibrateShort({
type: 'light'
});
},
// 格式化周次信息
formatWeeksInfo(course: any): string {
if (course.weeks) {
return course.weeks;
}
// 如果没有weeks字段根据startWeek、endWeek、weekType生成
const startWeek = course.startWeek || 1;
const endWeek = course.endWeek || 16;
const weekType = course.weekType || 0;
let weeksText = `${startWeek}-${endWeek}`;
if (weekType === 1) {
weeksText += '(单周)';
} else if (weekType === 2) {
weeksText += '(双周)';
}
return weeksText;
},
// 关闭详情弹窗
onCloseDetailModal() {
this.setData({
showCourseDetail: false,
selectedCourse: {}
});
},
// 阻止事件冒泡
onStopPropagation() {
// 空方法,用于阻止事件冒泡
},
// 编辑课程
onEditCourse() {
const course = this.data.selectedCourse;
this.onCloseDetailModal();
wx.navigateTo({
url: `/pages/course-add/course-add?id=${course.id}&mode=edit`
});
},
// 删除课程
onDeleteCourse() {
wx.showModal({
title: '确认删除',
content: '确定要删除这门课程吗?',
success: (res) => {
if (res.confirm) {
this.deleteCourse();
}
}
});
},
// 执行删除课程
async deleteCourse() {
const courseId = this.data.selectedCourse.id;
if (!courseId) return;
try {
const api = require('../../utils/api').default;
const res = await api.course.deleteCourse(courseId);
if (res.code === 200) {
wx.showToast({
title: '删除成功',
icon: 'success'
});
this.onCloseDetailModal();
this.loadCourses(); // 重新加载课程列表
} else {
api.handleError(res.message, '删除失败');
}
} catch (error) {
const api = require('../../utils/api').default;
api.handleError(error, '网络错误');
}
},
// 添加课程按钮点击
onAddCourseTap() {
wx.navigateTo({
url: '/pages/course-add/course-add?mode=add'
});
},
// OCR导入按钮点击
onOcrImportTap() {
wx.navigateTo({
url: '/pages/ocr-import/ocr-import'
});
},
// 上一周
onPrevWeek() {
const currentWeek = this.data.currentWeek;
if (currentWeek > 1) {
this.setData({ currentWeek: currentWeek - 1 });
this.updateWeekDays(); // 更新日期显示
this.loadCourses();
}
},
// 下一周
onNextWeek() {
const util = require('../../utils/util');
const semesterConfig = util.getSemesterConfig();
const currentWeek = this.data.currentWeek;
if (currentWeek < semesterConfig.totalWeeks) {
this.setData({ currentWeek: currentWeek + 1 });
this.updateWeekDays(); // 更新日期显示
this.loadCourses();
}
}
})

@ -0,0 +1,111 @@
<!--index.wxml-->
<view class="container">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title">我的课程表</text>
<view class="week-selector">
<text class="week-btn" bindtap="onPrevWeek"></text>
<text class="current-week">第{{currentWeek}}周</text>
<text class="week-btn" bindtap="onNextWeek"></text>
</view>
</view>
<!-- 星期标题栏 -->
<view class="week-header">
<view class="time-header">节次</view>
<view class="week-item" wx:for="{{weekDays}}" wx:key="index">
<text class="week-day">{{item.name}}</text>
<text class="week-date">{{item.date}}</text>
</view>
</view>
<!-- 课程表内容 -->
<scroll-view class="schedule-content" scroll-y="true">
<view wx:if="{{scheduleGrid.length > 0}}" class="schedule-grid">
<!-- 课程表行 -->
<view class="schedule-row" wx:for="{{scheduleGrid}}" wx:key="index" wx:for-item="row" wx:for-index="rowIndex">
<!-- 时间段列 -->
<view class="time-slot-cell">
<text class="time-slot-name">{{row.timeSlotName}}</text>
<text class="time-slot-time">{{row.timeSlotTime}}</text>
</view>
<!-- 课程列 -->
<view class="course-cell" wx:for="{{row.courses}}" wx:key="index" wx:for-item="course" wx:for-index="dayIndex">
<view wx:if="{{course}}"
class="course-item"
bindtap="onCourseItemTap"
bindlongpress="onCourseItemLongPress"
data-course="{{course}}">
<text class="course-name">{{course.courseName}}</text>
<text class="course-location">{{course.classroom}}</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view wx:else class="empty-state">
<image class="empty-icon" src="/images/empty-schedule.png" mode="aspectFit"></image>
<text class="empty-text">还没有课程安排</text>
<text class="empty-tip">点击下方按钮添加课程或使用OCR导入</text>
</view>
</scroll-view>
<!-- 底部操作按钮 -->
<view class="bottom-actions">
<button class="action-btn primary" bindtap="onAddCourseTap">
<text class="btn-text">添加课程</text>
</button>
<button class="action-btn secondary" bindtap="onOcrImportTap">
<text class="btn-text">OCR导入</text>
</button>
</view>
<!-- 课程详情弹窗 -->
<view wx:if="{{showCourseDetail}}" class="course-detail-modal" bindtap="onCloseDetailModal">
<view class="course-detail-content" catchtap="onStopPropagation">
<view class="detail-header">
<text class="detail-title">课程详情</text>
<view class="close-btn" bindtap="onCloseDetailModal">×</view>
</view>
<view class="detail-body">
<view class="detail-item">
<text class="detail-label">课程名称</text>
<text class="detail-value">{{selectedCourse.courseName}}</text>
</view>
<view class="detail-item">
<text class="detail-label">上课地点</text>
<text class="detail-value">{{selectedCourse.classroom}}</text>
</view>
<view class="detail-item" wx:if="{{selectedCourse.teacherName}}">
<text class="detail-label">任课教师</text>
<text class="detail-value">{{selectedCourse.teacherName}}</text>
</view>
<view class="detail-item">
<text class="detail-label">上课时间</text>
<text class="detail-value">{{selectedCourse.startTime}} - {{selectedCourse.endTime}}</text>
</view>
<view class="detail-item" wx:if="{{selectedCourse.formattedWeeks}}">
<text class="detail-label">周次</text>
<text class="detail-value">{{selectedCourse.formattedWeeks}}</text>
</view>
<view class="detail-item" wx:if="{{selectedCourse.notes}}">
<text class="detail-label">备注</text>
<text class="detail-value">{{selectedCourse.notes}}</text>
</view>
</view>
<view class="detail-actions">
<button class="detail-btn edit" bindtap="onEditCourse">编辑</button>
<button class="detail-btn delete" bindtap="onDeleteCourse">删除</button>
</view>
</view>
</view>
</view>

@ -0,0 +1,390 @@
/**index.wxss**/
.container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f8f8;
}
/* 页面标题 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
background-color: #1296db;
color: white;
}
.page-title {
font-size: 36rpx;
font-weight: bold;
}
/* 周次选择器 */
.week-selector {
display: flex;
align-items: center;
gap: 20rpx;
}
.week-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-size: 32rpx;
font-weight: bold;
transition: all 0.3s ease;
}
.week-btn:active {
background-color: rgba(255, 255, 255, 0.4);
transform: scale(0.95);
}
.current-week {
font-size: 28rpx;
opacity: 0.9;
min-width: 120rpx;
text-align: center;
}
/* 星期标题栏 */
.week-header {
display: flex;
background-color: white;
border-bottom: 1rpx solid #e5e5e5;
}
.time-header {
width: 140rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #333;
font-weight: bold;
background-color: #f5f5f5;
border-right: 1rpx solid #e5e5e5;
}
.week-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0;
}
.week-day {
font-size: 28rpx;
color: #333;
margin-bottom: 8rpx;
}
.week-date {
font-size: 24rpx;
color: #999;
}
/* 课程表内容 */
.schedule-content {
flex: 1;
background-color: white;
}
.schedule-grid {
display: flex;
flex-direction: column;
min-height: 100%;
}
.schedule-row {
display: flex;
min-height: 120rpx;
border-bottom: 1rpx solid #e5e5e5;
}
/* 时间段列 */
.time-slot-cell {
width: 140rpx;
background-color: #f5f5f5;
border-right: 1rpx solid #e5e5e5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8rpx;
}
.time-slot-name {
font-size: 24rpx;
color: #333;
font-weight: bold;
margin-bottom: 4rpx;
}
.time-slot-time {
font-size: 20rpx;
color: #666;
text-align: center;
line-height: 1.2;
}
/* 课程单元格 */
.course-cell {
flex: 1;
border-right: 1rpx solid #e5e5e5;
position: relative;
min-height: 120rpx;
}
.course-item {
position: absolute;
top: 4rpx;
left: 4rpx;
right: 4rpx;
bottom: 4rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8rpx;
padding: 8rpx;
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.1);
}
.course-name {
font-size: 22rpx;
color: white;
font-weight: bold;
margin-bottom: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-location {
font-size: 18rpx;
color: rgba(255,255,255,0.9);
margin-bottom: 2rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-teacher {
font-size: 18rpx;
color: rgba(255,255,255,0.8);
margin-bottom: 2rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-weeks {
font-size: 16rpx;
color: rgba(255,255,255,0.7);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 600rpx;
padding: 40rpx;
}
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: 30rpx;
opacity: 0.6;
}
.empty-text {
font-size: 32rpx;
color: #999;
margin-bottom: 16rpx;
}
.empty-tip {
font-size: 28rpx;
color: #ccc;
text-align: center;
line-height: 1.5;
}
/* 底部操作按钮 */
.bottom-actions {
display: flex;
padding: 20rpx 30rpx;
background-color: white;
border-top: 1rpx solid #e5e5e5;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
border: none;
}
.action-btn.primary {
background: linear-gradient(135deg, #1296db 0%, #0d7ec7 100%);
color: white;
}
.action-btn.secondary {
background-color: #f8f8f8;
color: #1296db;
border: 1rpx solid #1296db;
}
.btn-text {
font-size: 30rpx;
}
/* 课程详情弹窗 */
.course-detail-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.course-detail-content {
width: 600rpx;
max-height: 80vh;
background: white;
border-radius: 20rpx;
overflow: hidden;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.detail-title {
font-size: 32rpx;
font-weight: bold;
}
.close-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
.detail-body {
padding: 30rpx;
max-height: 500rpx;
overflow-y: auto;
}
.detail-item {
display: flex;
margin-bottom: 24rpx;
align-items: flex-start;
}
.detail-item:last-child {
margin-bottom: 0;
}
.detail-label {
width: 140rpx;
font-size: 28rpx;
color: #666;
font-weight: 500;
flex-shrink: 0;
}
.detail-value {
flex: 1;
font-size: 28rpx;
color: #333;
line-height: 1.4;
word-break: break-all;
}
.detail-actions {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.detail-btn {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
border: none;
background: white;
}
.detail-btn.edit {
color: #1296db;
border-right: 1rpx solid #f0f0f0;
}
.detail-btn.delete {
color: #ff4757;
}
.detail-btn:active {
background: #f8f8f8;
}

@ -0,0 +1,5 @@
{
"navigationBarTitleText": "OCR导入",
"navigationBarBackgroundColor": "#1296db",
"navigationBarTextStyle": "white"
}

@ -0,0 +1,384 @@
// ocr-import.ts
// 课程接口定义
interface Course {
id?: number;
courseName: string;
classroom: string;
teacherName?: string;
dayOfWeek: number;
startTime: string;
endTime: string;
notes?: string;
selected?: boolean;
}
// OCR结果接口
interface OcrResult {
success: boolean;
rawText?: string;
textLines?: string[];
courses?: Course[];
errorMessage?: string;
}
// 导入结果接口
interface ImportResult {
successCount: number;
errorCount: number;
errorMessages?: string[];
}
Page({
data: {
currentStep: 1, // 1: 选择图片, 2: OCR识别, 3: 导入结果
selectedImage: '',
ocrLoading: false,
importLoading: false,
ocrResult: {} as OcrResult,
importResult: {} as ImportResult,
weekNames: ['一', '二', '三', '四', '五', '六', '日'],
hasSelectedCourses: false,
// 编辑相关
showEditModal: false,
editingCourse: {} as Course,
editingIndex: -1,
weekTypeOptions: ['每周', '单周', '双周']
},
onLoad() {
// 页面加载时的初始化
},
// 选择图片
onChooseImage() {
wx.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
this.takePhoto();
} else if (res.tapIndex === 1) {
this.chooseFromAlbum();
}
}
});
},
// 拍照
takePhoto() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['camera'],
camera: 'back',
success: (res) => {
const tempFilePath = res.tempFiles[0].tempFilePath;
this.setData({
selectedImage: tempFilePath
});
},
fail: (error) => {
console.error('拍照失败:', error);
wx.showToast({
title: '拍照失败',
icon: 'none'
});
}
});
},
// 从相册选择
chooseFromAlbum() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album'],
success: (res) => {
const tempFilePath = res.tempFiles[0].tempFilePath;
this.setData({
selectedImage: tempFilePath
});
},
fail: (error) => {
console.error('选择图片失败:', error);
wx.showToast({
title: '选择图片失败',
icon: 'none'
});
}
});
},
// 开始OCR识别
async onStartOcr() {
if (!this.data.selectedImage) {
wx.showToast({
title: '请先选择图片',
icon: 'none'
});
return;
}
this.setData({
currentStep: 2,
ocrLoading: true
});
try {
// 上传图片并进行OCR识别
const uploadResult = await this.uploadImageForOcr(this.data.selectedImage);
if (uploadResult.success) {
// 为课程添加选中状态
const coursesWithSelection = (uploadResult.courses || []).map(course => ({
...course,
selected: true
}));
this.setData({
ocrResult: {
...uploadResult,
courses: coursesWithSelection
},
hasSelectedCourses: coursesWithSelection.length > 0
});
} else {
this.setData({
ocrResult: uploadResult
});
}
} catch (error) {
console.error('OCR识别失败:', error);
this.setData({
ocrResult: {
success: false,
errorMessage: '网络错误,请重试'
}
});
} finally {
this.setData({
ocrLoading: false
});
}
},
// 上传图片进行OCR识别
async uploadImageForOcr(imagePath: string): Promise<OcrResult> {
try {
const api = require('../../utils/api').default;
const userOpenid = api.getUserOpenid();
const res = await api.ocr.uploadImage(imagePath, userOpenid);
if (res.code === 200) {
return res.data;
} else {
return {
success: false,
errorMessage: res.message || 'OCR识别失败'
};
}
} catch (error) {
console.error('OCR识别失败:', error);
return {
success: false,
errorMessage: '网络错误,请重试'
};
}
},
// 切换课程选中状态
onCourseToggle(e: any) {
const index = e.currentTarget.dataset.index;
const checked = e.detail.value;
this.setData({
[`ocrResult.courses[${index}].selected`]: checked
});
// 检查是否有选中的课程
const hasSelected = this.data.ocrResult.courses?.some(course => course.selected) || false;
this.setData({
hasSelectedCourses: hasSelected
});
},
// 导入选中的课程
async onImportCourses() {
const selectedCourses = this.data.ocrResult.courses?.filter(course => course.selected) || [];
if (selectedCourses.length === 0) {
wx.showToast({
title: '请选择要导入的课程',
icon: 'none'
});
return;
}
this.setData({
currentStep: 3,
importLoading: true
});
try {
const api = require('../../utils/api').default;
const userOpenid = api.getUserOpenid();
const res = await api.ocr.importCourses(selectedCourses, userOpenid);
if (res.code === 200) {
const result = res.data;
this.setData({
importResult: {
successCount: result.successCourses?.length || 0,
errorCount: result.errorMessages?.length || 0,
errorMessages: result.errorMessages || []
}
});
} else {
throw new Error(res.message || '导入失败');
}
} catch (error) {
console.error('导入课程失败:', error);
const api = require('../../utils/api').default;
api.handleError(error, '导入失败');
// 回到上一步
this.setData({
currentStep: 2
});
} finally {
this.setData({
importLoading: false
});
}
},
// 返回第一步
onBackToStep1() {
this.setData({
currentStep: 1,
selectedImage: '',
ocrResult: {},
hasSelectedCourses: false
});
},
// 再次导入
onImportAgain() {
this.setData({
currentStep: 1,
selectedImage: '',
ocrResult: {},
importResult: {},
hasSelectedCourses: false
});
},
// 编辑课程
onEditCourse(e: any) {
const index = e.currentTarget.dataset.index;
const course = this.data.ocrResult.courses[index];
this.setData({
showEditModal: true,
editingCourse: { ...course },
editingIndex: index
});
},
// 关闭编辑弹窗
onCloseEditModal() {
this.setData({
showEditModal: false,
editingCourse: {},
editingIndex: -1
});
},
// 阻止事件冒泡
stopPropagation() {
// 空方法,用于阻止事件冒泡
},
// 编辑输入
onEditInput(e: any) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({
[`editingCourse.${field}`]: value
});
},
// 星期选择
onDayChange(e: any) {
const dayOfWeek = parseInt(e.detail.value) + 1;
this.setData({
'editingCourse.dayOfWeek': dayOfWeek
});
},
// 开始时间选择
onStartTimeChange(e: any) {
this.setData({
'editingCourse.startTime': e.detail.value
});
},
// 结束时间选择
onEndTimeChange(e: any) {
this.setData({
'editingCourse.endTime': e.detail.value
});
},
// 周次类型选择
onWeekTypeChange(e: any) {
this.setData({
'editingCourse.weekType': parseInt(e.detail.value)
});
},
// 保存编辑
onSaveEdit() {
const { editingCourse, editingIndex } = this.data;
// 验证必填字段
if (!editingCourse.courseName?.trim()) {
wx.showToast({
title: '请输入课程名称',
icon: 'none'
});
return;
}
if (!editingCourse.classroom?.trim()) {
wx.showToast({
title: '请输入上课地点',
icon: 'none'
});
return;
}
// 更新课程信息
this.setData({
[`ocrResult.courses[${editingIndex}]`]: editingCourse,
showEditModal: false,
editingCourse: {},
editingIndex: -1
});
wx.showToast({
title: '保存成功',
icon: 'success'
});
},
// 查看课表
onViewSchedule() {
wx.switchTab({
url: '/pages/index/index'
});
}
})

@ -0,0 +1,271 @@
<!--ocr-import.wxml-->
<view class="container">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title">OCR课表导入</text>
</view>
<!-- 步骤指示器 -->
<view class="step-indicator">
<view class="step {{currentStep >= 1 ? 'active' : ''}}">
<view class="step-number">1</view>
<text class="step-text">选择图片</text>
</view>
<view class="step-line {{currentStep >= 2 ? 'active' : ''}}"></view>
<view class="step {{currentStep >= 2 ? 'active' : ''}}">
<view class="step-number">2</view>
<text class="step-text">识别课程</text>
</view>
<view class="step-line {{currentStep >= 3 ? 'active' : ''}}"></view>
<view class="step {{currentStep >= 3 ? 'active' : ''}}">
<view class="step-number">3</view>
<text class="step-text">导入课表</text>
</view>
</view>
<!-- 内容区域 -->
<scroll-view class="content" scroll-y="true">
<!-- 步骤1: 选择图片 -->
<view wx:if="{{currentStep === 1}}" class="step-content">
<view class="upload-section">
<view class="upload-area" bindtap="onChooseImage">
<image wx:if="{{selectedImage}}"
class="preview-image"
src="{{selectedImage}}"
mode="aspectFit" />
<view wx:else class="upload-placeholder">
<image class="upload-icon" src="/images/camera-icon.png" mode="aspectFit" />
<text class="upload-text">点击选择课表图片</text>
<text class="upload-tip">支持拍照或从相册选择</text>
</view>
</view>
<view wx:if="{{selectedImage}}" class="image-actions">
<button class="action-btn secondary" bindtap="onChooseImage">
<text class="btn-text">重新选择</text>
</button>
<button class="action-btn primary" bindtap="onStartOcr">
<text class="btn-text">开始识别</text>
</button>
</view>
</view>
</view>
<!-- 步骤2: OCR识别中/结果 -->
<view wx:if="{{currentStep === 2}}" class="step-content">
<view wx:if="{{ocrLoading}}" class="loading-section">
<view class="loading-animation">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<text class="loading-text">正在识别课表信息...</text>
<text class="loading-tip">请稍候,这可能需要几秒钟</text>
</view>
<view wx:else class="ocr-result-section">
<view class="result-header">
<text class="result-title">识别结果</text>
<text class="result-count">共识别到 {{ocrResult.courses ? ocrResult.courses.length : 0}} 门课程</text>
</view>
<view wx:if="{{ocrResult.courses && ocrResult.courses.length > 0}}" class="course-list">
<view class="course-item"
wx:for="{{ocrResult.courses}}"
wx:key="index">
<view class="course-header">
<text class="course-name">{{item.courseName}}</text>
<view class="course-actions">
<button class="edit-btn"
bindtap="onEditCourse"
data-index="{{index}}"
size="mini">编辑</button>
<switch class="course-switch"
checked="{{item.selected}}"
bindchange="onCourseToggle"
data-index="{{index}}" />
</view>
</view>
<view class="course-details">
<text class="course-detail">{{item.classroom || '未识别到地点'}}</text>
<text class="course-detail">{{item.teacherName || '未识别到教师'}}</text>
<text class="course-detail">周{{weekNames[item.dayOfWeek - 1]}} {{item.startTime}}-{{item.endTime}}</text>
<text class="course-detail">第{{item.startWeek}}-{{item.endWeek}}周 {{item.weekType === 0 ? '每周' : (item.weekType === 1 ? '单周' : '双周')}}</text>
</view>
</view>
</view>
<view wx:else class="empty-result">
<image class="empty-icon" src="/images/empty-ocr.png" mode="aspectFit" />
<text class="empty-text">未识别到课程信息</text>
<text class="empty-tip">请尝试重新拍摄更清晰的课表图片</text>
</view>
<view class="result-actions">
<button class="action-btn secondary" bindtap="onBackToStep1">
<text class="btn-text">重新选择</text>
</button>
<button wx:if="{{hasSelectedCourses}}"
class="action-btn primary"
bindtap="onImportCourses">
<text class="btn-text">导入选中课程</text>
</button>
</view>
</view>
</view>
<!-- 步骤3: 导入结果 -->
<view wx:if="{{currentStep === 3}}" class="step-content">
<view wx:if="{{importLoading}}" class="loading-section">
<view class="loading-animation">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<text class="loading-text">正在导入课程...</text>
</view>
<view wx:else class="import-result-section">
<view class="result-icon">
<image class="success-icon" src="/images/success-icon.png" mode="aspectFit" />
</view>
<text class="result-title">导入完成</text>
<view class="import-summary">
<text class="summary-text">成功导入 {{importResult.successCount}} 门课程</text>
<text wx:if="{{importResult.errorCount > 0}}" class="error-text">
{{importResult.errorCount}} 门课程导入失败
</text>
</view>
<!-- 显示错误详情 -->
<view wx:if="{{importResult.errorMessages && importResult.errorMessages.length > 0}}" class="error-details">
<text class="error-title">失败原因:</text>
<view class="error-list">
<text wx:for="{{importResult.errorMessages}}" wx:key="index" class="error-item">
{{item}}
</text>
</view>
</view>
<view class="final-actions">
<button class="action-btn secondary" bindtap="onImportAgain">
<text class="btn-text">再次导入</text>
</button>
<button class="action-btn primary" bindtap="onViewSchedule">
<text class="btn-text">查看课表</text>
</button>
</view>
</view>
</view>
</scroll-view>
<!-- 课程编辑弹窗 -->
<view wx:if="{{showEditModal}}" class="modal-overlay" bindtap="onCloseEditModal">
<view class="edit-modal" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">编辑课程信息</text>
<button class="close-btn" bindtap="onCloseEditModal">×</button>
</view>
<view class="modal-content">
<view class="form-group">
<text class="form-label">课程名称</text>
<input class="form-input"
value="{{editingCourse.courseName}}"
bindinput="onEditInput"
data-field="courseName"
placeholder="请输入课程名称" />
</view>
<view class="form-group">
<text class="form-label">任课教师</text>
<input class="form-input"
value="{{editingCourse.teacherName}}"
bindinput="onEditInput"
data-field="teacherName"
placeholder="请输入教师姓名" />
</view>
<view class="form-group">
<text class="form-label">上课地点</text>
<input class="form-input"
value="{{editingCourse.classroom}}"
bindinput="onEditInput"
data-field="classroom"
placeholder="请输入上课地点" />
</view>
<view class="form-group">
<text class="form-label">星期</text>
<picker range="{{weekNames}}"
value="{{editingCourse.dayOfWeek - 1}}"
bindchange="onDayChange">
<view class="picker-display">
周{{weekNames[editingCourse.dayOfWeek - 1]}}
</view>
</picker>
</view>
<view class="form-row">
<view class="form-group half">
<text class="form-label">开始时间</text>
<picker mode="time"
value="{{editingCourse.startTime}}"
bindchange="onStartTimeChange">
<view class="picker-display">{{editingCourse.startTime}}</view>
</picker>
</view>
<view class="form-group half">
<text class="form-label">结束时间</text>
<picker mode="time"
value="{{editingCourse.endTime}}"
bindchange="onEndTimeChange">
<view class="picker-display">{{editingCourse.endTime}}</view>
</picker>
</view>
</view>
<view class="form-row">
<view class="form-group half">
<text class="form-label">开始周</text>
<input class="form-input"
type="number"
value="{{editingCourse.startWeek}}"
bindinput="onEditInput"
data-field="startWeek"
placeholder="1" />
</view>
<view class="form-group half">
<text class="form-label">结束周</text>
<input class="form-input"
type="number"
value="{{editingCourse.endWeek}}"
bindinput="onEditInput"
data-field="endWeek"
placeholder="16" />
</view>
</view>
<view class="form-group">
<text class="form-label">周次类型</text>
<picker range="{{weekTypeOptions}}"
value="{{editingCourse.weekType}}"
bindchange="onWeekTypeChange">
<view class="picker-display">
{{weekTypeOptions[editingCourse.weekType]}}
</view>
</picker>
</view>
</view>
<view class="modal-actions">
<button class="action-btn secondary" bindtap="onCloseEditModal">取消</button>
<button class="action-btn primary" bindtap="onSaveEdit">保存</button>
</view>
</view>
</view>
</view>

@ -0,0 +1,536 @@
/**ocr-import.wxss**/
.container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f8f8;
}
/* 页面标题 */
.page-header {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background-color: #1296db;
color: white;
}
.page-title {
font-size: 36rpx;
font-weight: bold;
}
/* 步骤指示器 */
.step-indicator {
display: flex;
align-items: center;
padding: 30rpx;
background-color: white;
border-bottom: 1rpx solid #e5e5e5;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-number {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #e5e5e5;
color: #999;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: bold;
margin-bottom: 12rpx;
}
.step.active .step-number {
background-color: #1296db;
color: white;
}
.step-text {
font-size: 24rpx;
color: #999;
}
.step.active .step-text {
color: #1296db;
}
.step-line {
flex: 1;
height: 2rpx;
background-color: #e5e5e5;
margin: 0 20rpx;
margin-bottom: 36rpx;
}
.step-line.active {
background-color: #1296db;
}
/* 内容区域 */
.content {
flex: 1;
padding: 30rpx;
}
.step-content {
height: 100%;
}
/* 上传区域 */
.upload-section {
display: flex;
flex-direction: column;
height: 100%;
}
.upload-area {
flex: 1;
border: 2rpx dashed #ccc;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: white;
min-height: 400rpx;
}
.preview-image {
width: 100%;
height: 100%;
border-radius: 16rpx;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx;
}
.upload-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 30rpx;
opacity: 0.6;
}
.upload-text {
font-size: 32rpx;
color: #666;
margin-bottom: 16rpx;
}
.upload-tip {
font-size: 28rpx;
color: #999;
}
.image-actions {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
}
/* 加载动画 */
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 60rpx;
}
.loading-animation {
display: flex;
gap: 12rpx;
margin-bottom: 40rpx;
}
.loading-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background-color: #1296db;
animation: loading 1.4s infinite ease-in-out;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loading {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.loading-text {
font-size: 32rpx;
color: #333;
margin-bottom: 16rpx;
}
.loading-tip {
font-size: 28rpx;
color: #999;
text-align: center;
}
/* OCR结果 */
.ocr-result-section {
height: 100%;
display: flex;
flex-direction: column;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
}
.result-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.result-count {
font-size: 28rpx;
color: #1296db;
}
.course-list {
flex: 1;
background-color: white;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 30rpx;
}
.course-item {
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.course-item:last-child {
border-bottom: none;
}
.course-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.course-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
flex: 1;
}
.course-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.edit-btn {
background-color: #1296db;
color: white;
border: none;
border-radius: 8rpx;
padding: 8rpx 16rpx;
font-size: 24rpx;
}
.course-switch {
transform: scale(0.8);
}
.course-details {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.course-detail {
font-size: 26rpx;
color: #666;
}
/* 空结果 */
.empty-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 60rpx;
}
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: 30rpx;
opacity: 0.6;
}
.empty-text {
font-size: 32rpx;
color: #999;
margin-bottom: 16rpx;
}
.empty-tip {
font-size: 28rpx;
color: #ccc;
text-align: center;
line-height: 1.5;
}
/* 导入结果 */
.import-result-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 60rpx;
}
.result-icon {
margin-bottom: 40rpx;
}
.success-icon {
width: 120rpx;
height: 120rpx;
}
.result-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.import-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
margin-bottom: 60rpx;
}
.summary-text {
font-size: 28rpx;
color: #666;
}
.error-text {
font-size: 26rpx;
color: #ff4757;
}
/* 错误详情样式 */
.error-details {
width: 100%;
background-color: #fff5f5;
border: 1rpx solid #fecaca;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 30rpx;
}
.error-title {
font-size: 28rpx;
font-weight: bold;
color: #dc2626;
margin-bottom: 16rpx;
display: block;
}
.error-list {
display: flex;
flex-direction: column;
}
.error-item {
font-size: 26rpx;
color: #991b1b;
line-height: 1.5;
margin-bottom: 8rpx;
padding-left: 20rpx;
position: relative;
}
.error-item::before {
content: "•";
position: absolute;
left: 0;
color: #dc2626;
}
/* 操作按钮 */
.result-actions,
.final-actions {
display: flex;
gap: 20rpx;
width: 100%;
}
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
border: none;
}
.action-btn.primary {
background: linear-gradient(135deg, #1296db 0%, #0d7ec7 100%);
color: white;
}
.action-btn.secondary {
background-color: #f8f8f8;
color: #1296db;
border: 1rpx solid #1296db;
}
.btn-text {
font-size: 30rpx;
}
/* 编辑弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.edit-modal {
background-color: white;
border-radius: 16rpx;
width: 90%;
max-width: 600rpx;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 40rpx;
color: #999;
padding: 0;
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.form-group {
margin-bottom: 32rpx;
}
.form-group.half {
flex: 1;
}
.form-row {
display: flex;
gap: 20rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 20rpx;
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
font-size: 28rpx;
background-color: #fafafa;
}
.form-input:focus {
border-color: #1296db;
background-color: white;
}
.picker-display {
padding: 20rpx;
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
font-size: 28rpx;
background-color: #fafafa;
color: #333;
}
.modal-actions {
display: flex;
gap: 20rpx;
padding: 32rpx;
border-top: 1rpx solid #f0f0f0;
}

@ -0,0 +1,5 @@
{
"navigationBarTitleText": "个人中心",
"navigationBarBackgroundColor": "#1296db",
"navigationBarTextStyle": "white"
}

@ -0,0 +1,227 @@
// profile.ts
// 用户信息接口
interface UserInfo {
avatarUrl?: string;
nickName?: string;
openid?: string;
}
Page({
data: {
userInfo: {} as UserInfo,
userOpenid: '',
appVersion: '1.0.0',
semesterConfig: {} as any,
currentWeek: 1
},
onLoad() {
this.loadUserInfo();
this.loadUserOpenid();
this.loadSemesterInfo();
},
onShow() {
// 页面显示时重新加载学期信息
this.loadSemesterInfo();
},
// 加载用户信息
loadUserInfo() {
// 从本地存储获取用户信息
const userInfo = wx.getStorageSync('userInfo') || {};
this.setData({ userInfo });
// 如果没有用户信息,尝试获取
if (!userInfo.nickName) {
this.getUserProfile();
}
},
// 加载用户OpenID
loadUserOpenid() {
const api = require('../../utils/api').default;
const userOpenid = api.getUserOpenid();
this.setData({ userOpenid });
},
// 加载学期信息
loadSemesterInfo() {
const util = require('../../utils/util');
const semesterConfig = util.getSemesterConfig();
const currentWeek = util.getCurrentWeek();
this.setData({
semesterConfig,
currentWeek
});
},
// 获取用户信息
getUserProfile() {
wx.getUserProfile({
desc: '用于完善用户资料',
success: (res) => {
const userInfo = res.userInfo;
this.setData({ userInfo });
// 保存到本地存储
wx.setStorageSync('userInfo', userInfo);
},
fail: (error) => {
console.log('获取用户信息失败:', error);
}
});
},
// 添加课程
onAddCourse() {
wx.navigateTo({
url: '/pages/course-add/course-add?mode=add'
});
},
// OCR导入
onOcrImport() {
wx.navigateTo({
url: '/pages/ocr-import/ocr-import'
});
},
// 导出课表
onExportSchedule() {
wx.showToast({
title: '功能开发中',
icon: 'none'
});
// TODO: 实现课表导出功能
},
// 清空所有课程
onClearAllCourses() {
wx.showModal({
title: '确认清空',
content: '确定要删除所有课程吗?此操作不可恢复。',
confirmText: '确定清空',
confirmColor: '#ff4757',
success: (res) => {
if (res.confirm) {
this.clearAllCourses();
}
}
});
},
// 执行清空操作
async clearAllCourses() {
wx.showLoading({
title: '清空中...'
});
try {
const api = require('../../utils/api').default;
const userOpenid = api.getUserOpenid();
const res = await api.course.clearAllCourses(userOpenid);
if (res.code === 200) {
wx.showToast({
title: '清空成功',
icon: 'success'
});
} else {
api.handleError(res.message, '清空失败');
}
} catch (error) {
const api = require('../../utils/api').default;
api.handleError(error, '网络错误');
} finally {
wx.hideLoading();
}
},
// 学期设置
onSemesterSetting() {
const util = require('../../utils/util');
const currentConfig = util.getSemesterConfig();
wx.showActionSheet({
itemList: ['春季学期设置', '秋季学期设置', '自定义设置'],
success: (res) => {
if (res.tapIndex === 0) {
// 春季学期
this.setSemesterConfig('spring');
} else if (res.tapIndex === 1) {
// 秋季学期
this.setSemesterConfig('fall');
} else if (res.tapIndex === 2) {
// 自定义设置
this.showCustomSemesterSetting();
}
}
});
},
// 设置学期配置
setSemesterConfig(type: 'spring' | 'fall') {
const util = require('../../utils/util');
const year = new Date().getFullYear();
let config;
if (type === 'spring') {
config = {
startDate: `${year}-02-26`,
name: `${year}年春季学期`,
totalWeeks: 18
};
} else {
config = {
startDate: `${year}-09-02`,
name: `${year}年秋季学期`,
totalWeeks: 18
};
}
util.setSemesterConfig(config);
this.loadSemesterInfo();
wx.showToast({
title: '设置成功',
icon: 'success'
});
},
// 显示自定义学期设置
showCustomSemesterSetting() {
const util = require('../../utils/util');
const currentConfig = util.getSemesterConfig();
wx.showModal({
title: '自定义学期设置',
content: `当前:${currentConfig.name}\n开始日期${currentConfig.startDate}\n总周数${currentConfig.totalWeeks}`,
showCancel: true,
confirmText: '修改',
success: (res) => {
if (res.confirm) {
// 这里可以跳转到详细设置页面,或者使用简单的输入框
wx.showToast({
title: '功能开发中',
icon: 'none'
});
}
}
});
},
// 关于我们
onAbout() {
wx.showModal({
title: '关于课表助手',
content: `版本:${this.data.appVersion}\n\n这是一个基于OCR技术的智能课表管理小程序支持拍照识别课表并自动导入课程信息。\n\n开发者智棋工作室`,
showCancel: false,
confirmText: '知道了'
});
}
})

@ -0,0 +1,89 @@
<!--profile.wxml-->
<view class="container">
<!-- 页面标题 -->
<view class="page-header">
<text class="page-title">个人中心</text>
</view>
<!-- 用户信息区域 -->
<view class="user-section">
<view class="user-info">
<image class="avatar" src="{{userInfo.avatarUrl || '/images/logo.png'}}" mode="aspectFill" />
<view class="user-details">
<text class="nickname">{{userInfo.nickName || '微信用户'}}</text>
<text class="user-id">ID: {{userOpenid}}</text>
</view>
</view>
</view>
<!-- 功能菜单 -->
<scroll-view class="menu-content" scroll-y="true">
<!-- 基础设置 -->
<view class="menu-section">
<text class="section-title">基础设置</text>
<view class="menu-list">
<view class="menu-item" bindtap="onAddCourse">
<view class="menu-content">
<text class="menu-title">添加课程</text>
<text class="menu-desc">手动添加新的课程</text>
</view>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" bindtap="onOcrImport">
<view class="menu-content">
<text class="menu-title">OCR导入</text>
<text class="menu-desc">拍照识别课表快速导入</text>
</view>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" bindtap="onSemesterSetting">
<view class="menu-content">
<text class="menu-title">学期设置</text>
<text class="menu-desc">{{semesterConfig.name}} (第{{currentWeek}}周)</text>
</view>
<text class="menu-arrow">></text>
</view>
</view>
</view>
<!-- 清空课程表 -->
<view class="menu-section">
<text class="section-title">数据管理</text>
<view class="menu-list">
<view class="menu-item" bindtap="onClearAllCourses">
<view class="menu-content">
<text class="menu-title danger">清空课程表</text>
<text class="menu-desc">删除所有课程数据</text>
</view>
<text class="menu-arrow">></text>
</view>
</view>
</view>
<!-- 关于我们 -->
<view class="menu-section">
<text class="section-title">关于</text>
<view class="menu-list">
<view class="menu-item" bindtap="onAbout">
<view class="menu-content">
<text class="menu-title">关于我们</text>
<text class="menu-desc">版本信息和开发团队</text>
</view>
<text class="menu-arrow">></text>
</view>
</view>
</view>
</scroll-view>
<!-- 版本信息 -->
<view class="footer">
<text class="version-text">课表助手 v{{appVersion}}</text>
<text class="copyright">© 2025 课表OCR识别小程序</text>
</view>
</view>

@ -0,0 +1,188 @@
/**profile.wxss**/
.container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f8f8;
}
/* 页面标题 */
.page-header {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background-color: #1296db;
color: white;
}
.page-title {
font-size: 36rpx;
font-weight: bold;
}
/* 用户信息区域 */
.user-section {
background: linear-gradient(135deg, #1296db 0%, #0d7ec7 100%);
padding: 40rpx 30rpx;
color: white;
}
.user-info {
display: flex;
align-items: center;
gap: 24rpx;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 4rpx solid rgba(255,255,255,0.3);
background-color: rgba(255,255,255,0.1);
padding: 8rpx;
box-sizing: border-box;
}
.user-details {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.nickname {
font-size: 36rpx;
font-weight: bold;
}
.user-id {
font-size: 26rpx;
opacity: 0.8;
}
/* 菜单内容 */
.menu-content {
flex: 1;
padding: 30rpx;
}
.menu-section {
margin-bottom: 40rpx;
}
.section-title {
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.menu-list {
background-color: white;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1);
}
.menu-item {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
transition: background-color 0.3s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:active {
background-color: #f8f8f8;
}
.menu-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.menu-title {
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.menu-title.danger {
color: #ff4757;
}
.menu-desc {
font-size: 26rpx;
color: #999;
}
.menu-arrow {
font-size: 28rpx;
color: #ccc;
}
/* 统计信息 */
.stats-section {
margin-bottom: 40rpx;
}
.stats-grid {
display: flex;
background-color: white;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1);
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 20rpx;
border-right: 1rpx solid #f0f0f0;
}
.stat-item:last-child {
border-right: none;
}
.stat-number {
font-size: 48rpx;
font-weight: bold;
color: #1296db;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 26rpx;
color: #666;
}
/* 底部信息 */
.footer {
display: flex;
flex-direction: column;
align-items: center;
padding: 30rpx;
gap: 8rpx;
background-color: white;
border-top: 1rpx solid #e5e5e5;
}
.version-text {
font-size: 26rpx;
color: #999;
}
.copyright {
font-size: 24rpx;
color: #ccc;
}

@ -0,0 +1,287 @@
// API接口封装
// 基础配置
const BASE_URL = 'http://localhost:8080/api'
// 课程接口定义
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=双周)
}
// API响应接口
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
// OCR结果接口
interface OcrResult {
success: boolean;
rawText?: string;
textLines?: string[];
courses?: Course[];
errorMessage?: string;
}
// 导入结果接口
interface ImportResult {
successCourses: Course[];
errorMessages: string[];
}
// 通用请求函数
const request = <T = any>(options: {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: any;
header?: any;
}): Promise<ApiResponse<T>> => {
return new Promise((resolve, reject) => {
wx.request({
url: `${BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data,
header: {
'content-type': 'application/json',
...options.header
},
success: (res: any) => {
if (res.statusCode === 200) {
resolve(res.data)
} else {
reject(new Error(`HTTP ${res.statusCode}: ${res.data?.message || '请求失败'}`))
}
},
fail: (error) => {
reject(new Error(`网络错误: ${error.errMsg || '请求失败'}`))
}
})
})
}
// 文件上传函数
const uploadFile = (options: {
url: string;
filePath: string;
name: string;
formData?: any;
}): Promise<ApiResponse> => {
return new Promise((resolve, reject) => {
wx.uploadFile({
url: `${BASE_URL}${options.url}`,
filePath: options.filePath,
name: options.name,
formData: options.formData,
success: (res) => {
try {
const data = JSON.parse(res.data)
if (res.statusCode === 200) {
resolve(data)
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data?.message || '上传失败'}`))
}
} catch (error) {
reject(new Error('响应数据解析失败'))
}
},
fail: (error) => {
reject(new Error(`上传失败: ${error.errMsg || '网络错误'}`))
}
})
})
}
// 课程管理API
export const courseApi = {
// 获取课程列表
getCourses: (userOpenid: string, dayOfWeek?: number, currentWeek?: number): Promise<ApiResponse<Course[]>> => {
let url = `/courses?userOpenid=${encodeURIComponent(userOpenid)}`;
if (dayOfWeek) {
url += `&dayOfWeek=${dayOfWeek}`;
}
if (currentWeek) {
url += `&currentWeek=${currentWeek}`;
}
return request<Course[]>({
url: url,
method: 'GET'
})
},
// 获取单个课程
getCourse: (id: number): Promise<ApiResponse<Course>> => {
return request<Course>({
url: `/courses/${id}`,
method: 'GET'
})
},
// 创建课程
createCourse: (course: Course): Promise<ApiResponse<Course>> => {
return request<Course>({
url: '/courses',
method: 'POST',
data: course
})
},
// 更新课程
updateCourse: (id: number, course: Course): Promise<ApiResponse<Course>> => {
return request<Course>({
url: `/courses/${id}`,
method: 'PUT',
data: course
})
},
// 删除课程
deleteCourse: (id: number): Promise<ApiResponse<void>> => {
return request<void>({
url: `/courses/${id}`,
method: 'DELETE'
})
},
// 清空所有课程
clearAllCourses: (userOpenid: string): Promise<ApiResponse<void>> => {
return request<void>({
url: `/courses/clear?userOpenid=${encodeURIComponent(userOpenid)}`,
method: 'DELETE'
})
}
}
// OCR识别API
export const ocrApi = {
// 上传图片进行OCR识别
uploadImage: (filePath: string, userOpenid: string): Promise<ApiResponse<OcrResult>> => {
return uploadFile({
url: '/ocr/upload',
filePath,
name: 'file',
formData: { userOpenid }
})
},
// 导入OCR识别的课程
importCourses: (courses: Course[], userOpenid: string): Promise<ApiResponse<ImportResult>> => {
return request<ImportResult>({
url: '/ocr/import',
method: 'POST',
data: { courses, userOpenid }
})
}
}
// 系统API
export const systemApi = {
// 健康检查
healthCheck: (): Promise<ApiResponse<any>> => {
return request({
url: '/health',
method: 'GET'
})
}
}
// OCR测试API开发阶段使用
export const ocrTestApi = {
// 测试OCR配置
testConfig: (): Promise<ApiResponse<any>> => {
return request({
url: '/ocr-test/config',
method: 'GET'
})
},
// 简单OCR测试
simpleTest: (imageUrl?: string): Promise<ApiResponse<any>> => {
return request({
url: '/ocr-test/simple',
method: 'POST',
data: imageUrl ? { imageUrl } : {}
})
},
// 获取OCR帮助信息
getHelp: (): Promise<ApiResponse<any>> => {
return request({
url: '/ocr-test/help',
method: 'GET'
})
}
}
// 错误处理工具
export const handleApiError = (error: any, defaultMessage: string = '操作失败') => {
console.error('API错误:', error)
let message = defaultMessage
if (error.message) {
message = error.message
} else if (typeof error === 'string') {
message = error
}
wx.showToast({
title: message,
icon: 'none',
duration: 2000
})
}
// API状态检查
export const checkApiStatus = async (): Promise<boolean> => {
try {
await systemApi.healthCheck()
return true
} catch (error) {
console.error('API服务不可用:', error)
return false
}
}
// 获取用户OpenID开发阶段使用固定ID
export const getUserOpenid = (): string => {
// 直接使用固定的用户ID避免清除缓存后数据丢失
const FIXED_USER_ID = 'my_fixed_user_003'
// 设置到本地存储(保持一致性)
wx.setStorageSync('userOpenid', FIXED_USER_ID)
console.log('使用固定用户ID:', FIXED_USER_ID)
return FIXED_USER_ID
}
// 重置用户ID为固定测试ID用于开发调试
export const resetUserOpenid = (): string => {
const FIXED_TEST_USER_ID = 'dev_test_user_003'
wx.setStorageSync('userOpenid', FIXED_TEST_USER_ID)
console.log('已重置为固定测试用户ID:', FIXED_TEST_USER_ID)
return FIXED_TEST_USER_ID
}
// 导出所有API
export default {
course: courseApi,
ocr: ocrApi,
system: systemApi,
ocrTest: ocrTestApi,
handleError: handleApiError,
checkStatus: checkApiStatus,
getUserOpenid // 固定用户ID函数
}

@ -0,0 +1,339 @@
// 格式化时间
export const formatTime = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
return (
[year, month, day].map(formatNumber).join('/') +
' ' +
[hour, minute, second].map(formatNumber).join(':')
)
}
const formatNumber = (n: number) => {
const s = n.toString()
return s[1] ? s : '0' + s
}
// 格式化日期为 MM/DD 格式
export const formatDate = (date: Date) => {
const month = date.getMonth() + 1
const day = date.getDate()
return `${formatNumber(month)}/${formatNumber(day)}`
}
// 获取当前周的日期范围
export const getCurrentWeekDates = () => {
const today = new Date()
const currentDay = today.getDay() // 0是周日1是周一
const monday = new Date(today)
monday.setDate(today.getDate() - (currentDay === 0 ? 6 : currentDay - 1))
const weekDays = []
const weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
for (let i = 0; i < 7; i++) {
const date = new Date(monday)
date.setDate(monday.getDate() + i)
weekDays.push({
name: weekNames[i],
date: formatDate(date),
fullDate: date
})
}
return weekDays
}
// 学期配置接口
interface SemesterConfig {
startDate: string;
name: string;
totalWeeks: number;
}
// 获取学期配置
export const getSemesterConfig = (): SemesterConfig => {
try {
const config = wx.getStorageSync('semesterConfig');
if (config) {
return JSON.parse(config);
}
} catch (error) {
console.warn('获取学期配置失败:', error);
}
// 默认配置:智能检测当前学期
return getDefaultSemesterConfig();
}
// 保存学期配置
export const setSemesterConfig = (config: SemesterConfig) => {
try {
wx.setStorageSync('semesterConfig', JSON.stringify(config));
} catch (error) {
console.error('保存学期配置失败:', error);
}
}
// 智能检测默认学期配置
export const getDefaultSemesterConfig = (): SemesterConfig => {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1; // 0-11 -> 1-12
if (month >= 2 && month <= 7) {
// 春季学期 (2月-7月)
return {
startDate: `${year}-02-26`, // 一般春季学期2月底开始
name: `${year}年春季学期`,
totalWeeks: 18
};
} else {
// 秋季学期 (8月-次年1月)
const semesterYear = month >= 8 ? year : year - 1;
return {
startDate: `${semesterYear}-09-01`, // 秋季学期9月1日开始周一
name: `${semesterYear}年秋季学期`,
totalWeeks: 18
};
}
}
// 获取当前是第几周(基于学期配置)
export const getCurrentWeek = (): number => {
const config = getSemesterConfig();
const semesterStart = new Date(config.startDate);
const today = new Date();
// 确保学期开始日期是周一
const semesterStartDay = semesterStart.getDay();
if (semesterStartDay !== 1) { // 如果不是周一,调整到周一
const daysToMonday = semesterStartDay === 0 ? -6 : 1 - semesterStartDay;
semesterStart.setDate(semesterStart.getDate() + daysToMonday);
}
// 计算今天是学期开始后的第几天
const diffTime = today.getTime() - semesterStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
// 计算周次第0-6天是第1周第7-13天是第2周以此类推
const week = Math.floor(diffDays / 7) + 1;
// 调试信息
console.log('周次计算调试信息:', {
: semesterStart.toDateString(),
: today.toDateString(),
: diffDays,
: week,
: Math.max(1, Math.min(week, config.totalWeeks))
});
// 确保周次在合理范围内
return Math.max(1, Math.min(week, config.totalWeeks));
}
// 时间字符串转换为分钟数(用于时间比较)
export const timeToMinutes = (timeStr: string): number => {
const [hours, minutes] = timeStr.split(':').map(Number)
return hours * 60 + minutes
}
// 分钟数转换为时间字符串
export const minutesToTime = (minutes: number): string => {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${formatNumber(hours)}:${formatNumber(mins)}`
}
// 验证时间格式
export const isValidTime = (timeStr: string): boolean => {
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/
return timeRegex.test(timeStr)
}
// 比较两个时间字符串
export const compareTime = (time1: string, time2: string): number => {
const minutes1 = timeToMinutes(time1)
const minutes2 = timeToMinutes(time2)
return minutes1 - minutes2
}
// 获取星期几的中文名称
export const getWeekDayName = (dayOfWeek: number): string => {
const weekNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
return weekNames[dayOfWeek] || ''
}
// 防抖函数
export const debounce = (func: Function, wait: number) => {
let timeout: number
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
// 节流函数
export const throttle = (func: Function, limit: number) => {
let inThrottle: boolean
return function executedFunction(...args: any[]) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
// 深拷贝
export const deepClone = (obj: any): any => {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj.getTime())
if (obj instanceof Array) return obj.map(item => deepClone(item))
if (typeof obj === 'object') {
const clonedObj: any = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
}
// 生成唯一ID
export const generateId = (): string => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 存储相关工具函数
export const storage = {
set: (key: string, value: any) => {
try {
wx.setStorageSync(key, value)
} catch (error) {
console.error('存储失败:', error)
}
},
get: (key: string, defaultValue: any = null) => {
try {
return wx.getStorageSync(key) || defaultValue
} catch (error) {
console.error('读取存储失败:', error)
return defaultValue
}
},
remove: (key: string) => {
try {
wx.removeStorageSync(key)
} catch (error) {
console.error('删除存储失败:', error)
}
},
clear: () => {
try {
wx.clearStorageSync()
} catch (error) {
console.error('清空存储失败:', error)
}
}
}
// 网络请求工具函数
export const request = {
baseURL: 'http://localhost:8080/api',
get: (url: string, data?: any) => {
return wx.request({
url: `${request.baseURL}${url}`,
method: 'GET',
data,
header: {
'content-type': 'application/json'
}
})
},
post: (url: string, data?: any) => {
return wx.request({
url: `${request.baseURL}${url}`,
method: 'POST',
data,
header: {
'content-type': 'application/json'
}
})
},
put: (url: string, data?: any) => {
return wx.request({
url: `${request.baseURL}${url}`,
method: 'PUT',
data,
header: {
'content-type': 'application/json'
}
})
},
delete: (url: string, data?: any) => {
return wx.request({
url: `${request.baseURL}${url}`,
method: 'DELETE',
data,
header: {
'content-type': 'application/json'
}
})
}
}
// 显示提示信息
export const showToast = (title: string, icon: 'success' | 'error' | 'loading' | 'none' = 'none') => {
wx.showToast({
title,
icon,
duration: 2000
})
}
// 显示加载中
export const showLoading = (title: string = '加载中...') => {
wx.showLoading({
title,
mask: true
})
}
// 隐藏加载中
export const hideLoading = () => {
wx.hideLoading()
}
// 显示确认对话框
export const showConfirm = (content: string, title: string = '提示'): Promise<boolean> => {
return new Promise((resolve) => {
wx.showModal({
title,
content,
success: (res) => {
resolve(res.confirm)
},
fail: () => {
resolve(false)
}
})
})
}

@ -0,0 +1,15 @@
{
"name": "miniprogram-ts-quickstart",
"version": "1.0.0",
"description": "",
"scripts": {
},
"keywords": [],
"author": "",
"license": "",
"dependencies": {
},
"devDependencies": {
"miniprogram-api-typings": "^2.8.3-1"
}
}

@ -0,0 +1,38 @@
{
"description": "项目配置文件",
"miniprogramRoot": "miniprogram/",
"compileType": "miniprogram",
"setting": {
"useCompilerPlugins": [
"typescript"
],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"coverView": false,
"postcss": false,
"minified": false,
"enhance": false,
"showShadowRootInWxmlPanel": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": true,
"compileHotReLoad": false,
"skylineRenderEnable": true
},
"simulatorType": "wechat",
"simulatorPluginLibVersion": {},
"condition": {},
"srcMiniprogramRoot": "miniprogram/",
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"libVersion": "trial",
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxd13571dd1337b69d"
}

@ -0,0 +1,8 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "schedule-ocr-miniprogram",
"setting": {
"compileHotReLoad": true,
"urlCheck": false
}
}

@ -0,0 +1,30 @@
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true,
"module": "CommonJS",
"target": "ES2020",
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"alwaysStrict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,
"strictPropertyInitialization": true,
"lib": ["ES2020"],
"typeRoots": [
"./typings"
]
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules"
]
}

@ -0,0 +1,8 @@
/// <reference path="./types/index.d.ts" />
interface IAppOption {
globalData: {
userInfo?: WechatMiniprogram.UserInfo,
}
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
}

@ -0,0 +1 @@
/// <reference path="./wx/index.d.ts" />

@ -0,0 +1,74 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
/// <reference path="./lib.wx.app.d.ts" />
/// <reference path="./lib.wx.page.d.ts" />
/// <reference path="./lib.wx.api.d.ts" />
/// <reference path="./lib.wx.cloud.d.ts" />
/// <reference path="./lib.wx.component.d.ts" />
/// <reference path="./lib.wx.behavior.d.ts" />
/// <reference path="./lib.wx.event.d.ts" />
declare namespace WechatMiniprogram {
type IAnyObject = Record<string, any>
type Optional<F> = F extends (arg: infer P) => infer R ? (arg?: P) => R : F
type OptionalInterface<T> = { [K in keyof T]: Optional<T[K]> }
interface AsyncMethodOptionLike {
success?: (...args: any[]) => void
}
type PromisifySuccessResult<
P,
T extends AsyncMethodOptionLike
> = P extends { success: any }
? void
: P extends { fail: any }
? void
: P extends { complete: any }
? void
: Promise<Parameters<Exclude<T['success'], undefined>>[0]>
}
declare const console: WechatMiniprogram.Console
declare const wx: WechatMiniprogram.Wx
/** 引入模块。返回模块通过 `module.exports` 或 `exports` 暴露的接口。 */
declare function require(
/** 需要引入模块文件相对于当前文件的相对路径,或 npm 模块名,或 npm 模块路径。不支持绝对路径 */
module: string
): any
/** 引入插件。返回插件通过 `main` 暴露的接口。 */
declare function requirePlugin(
/** 需要引入的插件的 alias */
module: string
): any
/** 插件引入当前使用者小程序。返回使用者小程序通过 [插件配置中 `export` 暴露的接口](https://developers.weixin.qq.com/miniprogram/dev/framework/plugin/using.html#%E5%AF%BC%E5%87%BA%E5%88%B0%E6%8F%92%E4%BB%B6)
*
*
*
* `2.11.1` */
declare function requireMiniProgram(): any
/** 当前模块对象 */
declare let module: {
/** 模块向外暴露的对象,使用 `require` 引用该模块时可以获取 */
exports: any
}
/** `module.exports` 的引用 */
declare let exports: any

File diff suppressed because it is too large Load Diff

@ -0,0 +1,270 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.App {
interface ReferrerInfo {
/** App appId
*
* referrerInfo.appId
* - 1020 profile appId
* - 1035 appId
* - 1036App appId
* - 1037 appId
* - 1038 appId
* - 1043 appId
*/
appId: string
/** 来源小程序传过来的数据scene=1037或1038时支持 */
extraData?: any
}
type SceneValues =
| 1001
| 1005
| 1006
| 1007
| 1008
| 1011
| 1012
| 1013
| 1014
| 1017
| 1019
| 1020
| 1023
| 1024
| 1025
| 1026
| 1027
| 1028
| 1029
| 1030
| 1031
| 1032
| 1034
| 1035
| 1036
| 1037
| 1038
| 1039
| 1042
| 1043
| 1044
| 1045
| 1046
| 1047
| 1048
| 1049
| 1052
| 1053
| 1056
| 1057
| 1058
| 1059
| 1064
| 1067
| 1069
| 1071
| 1072
| 1073
| 1074
| 1077
| 1078
| 1079
| 1081
| 1082
| 1084
| 1089
| 1090
| 1091
| 1092
| 1095
| 1096
| 1097
| 1099
| 1102
| 1124
| 1125
| 1126
| 1129
interface LaunchShowOption {
/** 打开小程序的路径 */
path: string
/** 打开小程序的query */
query: IAnyObject
/**
* - 1001使2.2.4
* - 1005
* - 1006
* - 1007
* - 1008
* - 1011
* - 1012
* - 1013
* - 1014
* - 1017
* - 10197.0.0
* - 1020 profile
* - 1023
* - 1024 profile
* - 1025
* - 1026
* - 1027使
* - 1028
* - 1029
* - 1030
* - 1031
* - 1032
* - 1034
* - 1035
* - 1036App
* - 1037
* - 1038
* - 1039
* - 1042
* - 1043
* - 1044 shareTicket [](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html)
* - 1045广
* - 1046广
* - 1047
* - 1048
* - 1049
* - 1052
* - 1053
* - 1056
* - 1057
* - 1058
* - 1059
* - 1064Wi-Fi
* - 1067广
* - 1069
* - 1071
* - 1072
* - 1073
* - 1074
* - 1077
* - 1078Wi-Fi
* - 1079
* - 1081
* - 1082
* - 1084广
* - 1089使2.2.4
* - 1090使
* - 1091
* - 1092
* - 1095广
* - 1096
* - 1097
* - 1099
* - 1102 profile
* - 1124
* - 1125
* - 1126
* - 1129访 [](https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/sitemap.html)
*/
scene: SceneValues
/** shareTicket详见 [获取更多转发信息]((转发#获取更多转发信息)) */
shareTicket: string
/** 当场景为由从另一个小程序或公众号或App打开时返回此字段 */
referrerInfo?: ReferrerInfo
}
interface PageNotFoundOption {
/** 不存在页面的路径 */
path: string
/** 打开不存在页面的 query */
query: IAnyObject
/** 是否本次启动的首个页面(例如从分享等入口进来,首个页面是开发者配置的分享页面) */
isEntryPage: boolean
}
interface Option {
/**
*
*
*/
onLaunch(options: LaunchShowOption): void
/**
*
*
*/
onShow(options: LaunchShowOption): void
/**
*
*
*/
onHide(): void
/**
*
* api
*/
onError(/** 错误信息,包含堆栈 */ error: string): void
/**
*
*
*
* ****
* 1. `onPageNotFound`
* 2. `onPageNotFound` `onPageNotFound`
*
* 1.9.90
*/
onPageNotFound(options: PageNotFoundOption): void
/**
* Promise 使 [wx.onUnhandledRejection](https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onUnhandledRejection.html) 绑定监听。注意事项请参考 [wx.onUnhandledRejection](https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onUnhandledRejection.html)。
* **** [wx.onUnhandledRejection](https://developers.weixin.qq.com/miniprogram/dev/api/base/app/app-event/wx.onUnhandledRejection.html) 一致
*/
onUnhandledRejection: OnUnhandledRejectionCallback
/**
* 使 wx.onThemeChange
*
* 2.11.0
*/
onThemeChange: OnThemeChangeCallback
}
type Instance<T extends IAnyObject> = Option & T
type Options<T extends IAnyObject> = Partial<Option> &
T &
ThisType<Instance<T>>
type TrivialInstance = Instance<IAnyObject>
interface Constructor {
<T extends IAnyObject>(options: Options<T>): void
}
interface GetAppOption {
/** `App` AppApp
*
* 2.2.4
*/
allowDefault?: boolean
}
interface GetApp {
<T = IAnyObject>(opts?: GetAppOption): Instance<T>
}
}
declare let App: WechatMiniprogram.App.Constructor
declare let getApp: WechatMiniprogram.App.GetApp

@ -0,0 +1,68 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.Behavior {
type BehaviorIdentifier = string
type Instance<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = Record<string, never>
> = Component.Instance<TData, TProperty, TMethod, TCustomInstanceProperty>
type TrivialInstance = Instance<IAnyObject, IAnyObject, IAnyObject>
type TrivialOption = Options<IAnyObject, IAnyObject, IAnyObject>
type Options<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = Record<string, never>
> = Partial<Data<TData>> &
Partial<Property<TProperty>> &
Partial<Method<TMethod>> &
Partial<OtherOption> &
Partial<Lifetimes> &
ThisType<Instance<TData, TProperty, TMethod, TCustomInstanceProperty>>
interface Constructor {
<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = Record<string, never>
>(
options: Options<TData, TProperty, TMethod, TCustomInstanceProperty>
): BehaviorIdentifier
}
type DataOption = Component.DataOption
type PropertyOption = Component.PropertyOption
type MethodOption = Component.MethodOption
type Data<D extends DataOption> = Component.Data<D>
type Property<P extends PropertyOption> = Component.Property<P>
type Method<M extends MethodOption> = Component.Method<M>
type DefinitionFilter = Component.DefinitionFilter
type Lifetimes = Component.Lifetimes
type OtherOption = Omit<Component.OtherOption, 'options'>
}
/** 注册一个 `behavior`,接受一个 `Object` 类型的参数。*/
declare let Behavior: WechatMiniprogram.Behavior.Constructor

@ -0,0 +1,924 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
interface IAPIError {
errMsg: string
}
interface IAPIParam<T = any> {
config?: ICloudConfig
success?: (res: T) => void
fail?: (err: IAPIError) => void
complete?: (val: T | IAPIError) => void
}
interface IAPISuccessParam {
errMsg: string
}
type IAPICompleteParam = IAPISuccessParam | IAPIError
type IAPIFunction<T, P extends IAPIParam<T>> = (param?: P) => Promise<T>
interface IInitCloudConfig {
env?:
| string
| {
database?: string
functions?: string
storage?: string
}
traceUser?: boolean
}
interface ICloudConfig {
env?: string
traceUser?: boolean
}
interface IICloudAPI {
init: (config?: IInitCloudConfig) => void
[api: string]: AnyFunction | IAPIFunction<any, any>
}
interface ICloudService {
name: string
getAPIs: () => { [name: string]: IAPIFunction<any, any> }
}
interface ICloudServices {
[serviceName: string]: ICloudService
}
interface ICloudMetaData {
session_id: string
}
declare class InternalSymbol {}
interface AnyObject {
[x: string]: any
}
type AnyArray = any[]
type AnyFunction = (...args: any[]) => any
/**
* extend wx with cloud
*/
interface WxCloud {
init: (config?: ICloudConfig) => void
callFunction(param: OQ<ICloud.CallFunctionParam>): void
callFunction(
param: RQ<ICloud.CallFunctionParam>
): Promise<ICloud.CallFunctionResult>
uploadFile(param: OQ<ICloud.UploadFileParam>): WechatMiniprogram.UploadTask
uploadFile(
param: RQ<ICloud.UploadFileParam>
): Promise<ICloud.UploadFileResult>
downloadFile(
param: OQ<ICloud.DownloadFileParam>
): WechatMiniprogram.DownloadTask
downloadFile(
param: RQ<ICloud.DownloadFileParam>
): Promise<ICloud.DownloadFileResult>
getTempFileURL(param: OQ<ICloud.GetTempFileURLParam>): void
getTempFileURL(
param: RQ<ICloud.GetTempFileURLParam>
): Promise<ICloud.GetTempFileURLResult>
deleteFile(param: OQ<ICloud.DeleteFileParam>): void
deleteFile(
param: RQ<ICloud.DeleteFileParam>
): Promise<ICloud.DeleteFileResult>
database: (config?: ICloudConfig) => DB.Database
CloudID: ICloud.ICloudIDConstructor
CDN: ICloud.ICDNConstructor
}
declare namespace ICloud {
interface ICloudAPIParam<T = any> extends IAPIParam<T> {
config?: ICloudConfig
}
// === API: callFunction ===
type CallFunctionData = AnyObject
interface CallFunctionResult extends IAPISuccessParam {
result: AnyObject | string | undefined
}
interface CallFunctionParam extends ICloudAPIParam<CallFunctionResult> {
name: string
data?: CallFunctionData
slow?: boolean
}
// === end ===
// === API: uploadFile ===
interface UploadFileResult extends IAPISuccessParam {
fileID: string
statusCode: number
}
interface UploadFileParam extends ICloudAPIParam<UploadFileResult> {
cloudPath: string
filePath: string
header?: AnyObject
}
// === end ===
// === API: downloadFile ===
interface DownloadFileResult extends IAPISuccessParam {
tempFilePath: string
statusCode: number
}
interface DownloadFileParam extends ICloudAPIParam<DownloadFileResult> {
fileID: string
cloudPath?: string
}
// === end ===
// === API: getTempFileURL ===
interface GetTempFileURLResult extends IAPISuccessParam {
fileList: GetTempFileURLResultItem[]
}
interface GetTempFileURLResultItem {
fileID: string
tempFileURL: string
maxAge: number
status: number
errMsg: string
}
interface GetTempFileURLParam extends ICloudAPIParam<GetTempFileURLResult> {
fileList: string[]
}
// === end ===
// === API: deleteFile ===
interface DeleteFileResult extends IAPISuccessParam {
fileList: DeleteFileResultItem[]
}
interface DeleteFileResultItem {
fileID: string
status: number
errMsg: string
}
interface DeleteFileParam extends ICloudAPIParam<DeleteFileResult> {
fileList: string[]
}
// === end ===
// === API: CloudID ===
abstract class CloudID {
constructor(cloudID: string)
}
interface ICloudIDConstructor {
new (cloudId: string): CloudID
(cloudId: string): CloudID
}
// === end ===
// === API: CDN ===
abstract class CDN {
target: string | ArrayBuffer | ICDNFilePathSpec
constructor(target: string | ArrayBuffer | ICDNFilePathSpec)
}
interface ICDNFilePathSpec {
type: 'filePath'
filePath: string
}
interface ICDNConstructor {
new (options: string | ArrayBuffer | ICDNFilePathSpec): CDN
(options: string | ArrayBuffer | ICDNFilePathSpec): CDN
}
// === end ===
}
// === Database ===
declare namespace DB {
/**
* The class of all exposed cloud database instances
*/
class Database {
readonly config: ICloudConfig
readonly command: DatabaseCommand
readonly Geo: IGeo
readonly serverDate: () => ServerDate
readonly RegExp: IRegExpConstructor
private constructor()
collection(collectionName: string): CollectionReference
}
class CollectionReference extends Query {
readonly collectionName: string
private constructor(name: string, database: Database)
doc(docId: string | number): DocumentReference
add(options: OQ<IAddDocumentOptions>): void
add(options: RQ<IAddDocumentOptions>): Promise<IAddResult>
}
class DocumentReference {
private constructor(docId: string | number, database: Database)
field(object: Record<string, any>): this
get(options: OQ<IGetDocumentOptions>): void
get(options?: RQ<IGetDocumentOptions>): Promise<IQuerySingleResult>
set(options: OQ<ISetSingleDocumentOptions>): void
set(options?: RQ<ISetSingleDocumentOptions>): Promise<ISetResult>
update(options: OQ<IUpdateSingleDocumentOptions>): void
update(
options?: RQ<IUpdateSingleDocumentOptions>
): Promise<IUpdateResult>
remove(options: OQ<IRemoveSingleDocumentOptions>): void
remove(
options?: RQ<IRemoveSingleDocumentOptions>
): Promise<IRemoveResult>
watch(options: IWatchOptions): RealtimeListener
}
class RealtimeListener {
// "And Now His Watch Is Ended"
close: () => Promise<void>
}
class Query {
where(condition: IQueryCondition): Query
orderBy(fieldPath: string, order: string): Query
limit(max: number): Query
skip(offset: number): Query
field(object: Record<string, any>): Query
get(options: OQ<IGetDocumentOptions>): void
get(options?: RQ<IGetDocumentOptions>): Promise<IQueryResult>
count(options: OQ<ICountDocumentOptions>): void
count(options?: RQ<ICountDocumentOptions>): Promise<ICountResult>
watch(options: IWatchOptions): RealtimeListener
}
interface DatabaseCommand {
eq(val: any): DatabaseQueryCommand
neq(val: any): DatabaseQueryCommand
gt(val: any): DatabaseQueryCommand
gte(val: any): DatabaseQueryCommand
lt(val: any): DatabaseQueryCommand
lte(val: any): DatabaseQueryCommand
in(val: any[]): DatabaseQueryCommand
nin(val: any[]): DatabaseQueryCommand
geoNear(options: IGeoNearCommandOptions): DatabaseQueryCommand
geoWithin(options: IGeoWithinCommandOptions): DatabaseQueryCommand
geoIntersects(
options: IGeoIntersectsCommandOptions
): DatabaseQueryCommand
and(
...expressions: Array<DatabaseLogicCommand | IQueryCondition>
): DatabaseLogicCommand
or(
...expressions: Array<DatabaseLogicCommand | IQueryCondition>
): DatabaseLogicCommand
nor(
...expressions: Array<DatabaseLogicCommand | IQueryCondition>
): DatabaseLogicCommand
not(expression: DatabaseLogicCommand): DatabaseLogicCommand
exists(val: boolean): DatabaseQueryCommand
mod(divisor: number, remainder: number): DatabaseQueryCommand
all(val: any[]): DatabaseQueryCommand
elemMatch(val: any): DatabaseQueryCommand
size(val: number): DatabaseQueryCommand
set(val: any): DatabaseUpdateCommand
remove(): DatabaseUpdateCommand
inc(val: number): DatabaseUpdateCommand
mul(val: number): DatabaseUpdateCommand
min(val: number): DatabaseUpdateCommand
max(val: number): DatabaseUpdateCommand
rename(val: string): DatabaseUpdateCommand
bit(val: number): DatabaseUpdateCommand
push(...values: any[]): DatabaseUpdateCommand
pop(): DatabaseUpdateCommand
shift(): DatabaseUpdateCommand
unshift(...values: any[]): DatabaseUpdateCommand
addToSet(val: any): DatabaseUpdateCommand
pull(val: any): DatabaseUpdateCommand
pullAll(val: any): DatabaseUpdateCommand
project: {
slice(val: number | [number, number]): DatabaseProjectionCommand
}
aggregate: {
__safe_props__?: Set<string>
abs(val: any): DatabaseAggregateCommand
add(val: any): DatabaseAggregateCommand
addToSet(val: any): DatabaseAggregateCommand
allElementsTrue(val: any): DatabaseAggregateCommand
and(val: any): DatabaseAggregateCommand
anyElementTrue(val: any): DatabaseAggregateCommand
arrayElemAt(val: any): DatabaseAggregateCommand
arrayToObject(val: any): DatabaseAggregateCommand
avg(val: any): DatabaseAggregateCommand
ceil(val: any): DatabaseAggregateCommand
cmp(val: any): DatabaseAggregateCommand
concat(val: any): DatabaseAggregateCommand
concatArrays(val: any): DatabaseAggregateCommand
cond(val: any): DatabaseAggregateCommand
convert(val: any): DatabaseAggregateCommand
dateFromParts(val: any): DatabaseAggregateCommand
dateToParts(val: any): DatabaseAggregateCommand
dateFromString(val: any): DatabaseAggregateCommand
dateToString(val: any): DatabaseAggregateCommand
dayOfMonth(val: any): DatabaseAggregateCommand
dayOfWeek(val: any): DatabaseAggregateCommand
dayOfYear(val: any): DatabaseAggregateCommand
divide(val: any): DatabaseAggregateCommand
eq(val: any): DatabaseAggregateCommand
exp(val: any): DatabaseAggregateCommand
filter(val: any): DatabaseAggregateCommand
first(val: any): DatabaseAggregateCommand
floor(val: any): DatabaseAggregateCommand
gt(val: any): DatabaseAggregateCommand
gte(val: any): DatabaseAggregateCommand
hour(val: any): DatabaseAggregateCommand
ifNull(val: any): DatabaseAggregateCommand
in(val: any): DatabaseAggregateCommand
indexOfArray(val: any): DatabaseAggregateCommand
indexOfBytes(val: any): DatabaseAggregateCommand
indexOfCP(val: any): DatabaseAggregateCommand
isArray(val: any): DatabaseAggregateCommand
isoDayOfWeek(val: any): DatabaseAggregateCommand
isoWeek(val: any): DatabaseAggregateCommand
isoWeekYear(val: any): DatabaseAggregateCommand
last(val: any): DatabaseAggregateCommand
let(val: any): DatabaseAggregateCommand
literal(val: any): DatabaseAggregateCommand
ln(val: any): DatabaseAggregateCommand
log(val: any): DatabaseAggregateCommand
log10(val: any): DatabaseAggregateCommand
lt(val: any): DatabaseAggregateCommand
lte(val: any): DatabaseAggregateCommand
ltrim(val: any): DatabaseAggregateCommand
map(val: any): DatabaseAggregateCommand
max(val: any): DatabaseAggregateCommand
mergeObjects(val: any): DatabaseAggregateCommand
meta(val: any): DatabaseAggregateCommand
min(val: any): DatabaseAggregateCommand
millisecond(val: any): DatabaseAggregateCommand
minute(val: any): DatabaseAggregateCommand
mod(val: any): DatabaseAggregateCommand
month(val: any): DatabaseAggregateCommand
multiply(val: any): DatabaseAggregateCommand
neq(val: any): DatabaseAggregateCommand
not(val: any): DatabaseAggregateCommand
objectToArray(val: any): DatabaseAggregateCommand
or(val: any): DatabaseAggregateCommand
pow(val: any): DatabaseAggregateCommand
push(val: any): DatabaseAggregateCommand
range(val: any): DatabaseAggregateCommand
reduce(val: any): DatabaseAggregateCommand
reverseArray(val: any): DatabaseAggregateCommand
rtrim(val: any): DatabaseAggregateCommand
second(val: any): DatabaseAggregateCommand
setDifference(val: any): DatabaseAggregateCommand
setEquals(val: any): DatabaseAggregateCommand
setIntersection(val: any): DatabaseAggregateCommand
setIsSubset(val: any): DatabaseAggregateCommand
setUnion(val: any): DatabaseAggregateCommand
size(val: any): DatabaseAggregateCommand
slice(val: any): DatabaseAggregateCommand
split(val: any): DatabaseAggregateCommand
sqrt(val: any): DatabaseAggregateCommand
stdDevPop(val: any): DatabaseAggregateCommand
stdDevSamp(val: any): DatabaseAggregateCommand
strcasecmp(val: any): DatabaseAggregateCommand
strLenBytes(val: any): DatabaseAggregateCommand
strLenCP(val: any): DatabaseAggregateCommand
substr(val: any): DatabaseAggregateCommand
substrBytes(val: any): DatabaseAggregateCommand
substrCP(val: any): DatabaseAggregateCommand
subtract(val: any): DatabaseAggregateCommand
sum(val: any): DatabaseAggregateCommand
switch(val: any): DatabaseAggregateCommand
toBool(val: any): DatabaseAggregateCommand
toDate(val: any): DatabaseAggregateCommand
toDecimal(val: any): DatabaseAggregateCommand
toDouble(val: any): DatabaseAggregateCommand
toInt(val: any): DatabaseAggregateCommand
toLong(val: any): DatabaseAggregateCommand
toObjectId(val: any): DatabaseAggregateCommand
toString(val: any): DatabaseAggregateCommand
toLower(val: any): DatabaseAggregateCommand
toUpper(val: any): DatabaseAggregateCommand
trim(val: any): DatabaseAggregateCommand
trunc(val: any): DatabaseAggregateCommand
type(val: any): DatabaseAggregateCommand
week(val: any): DatabaseAggregateCommand
year(val: any): DatabaseAggregateCommand
zip(val: any): DatabaseAggregateCommand
}
}
class DatabaseAggregateCommand {}
enum LOGIC_COMMANDS_LITERAL {
AND = 'and',
OR = 'or',
NOT = 'not',
NOR = 'nor'
}
class DatabaseLogicCommand {
and(...expressions: DatabaseLogicCommand[]): DatabaseLogicCommand
or(...expressions: DatabaseLogicCommand[]): DatabaseLogicCommand
nor(...expressions: DatabaseLogicCommand[]): DatabaseLogicCommand
not(expression: DatabaseLogicCommand): DatabaseLogicCommand
}
enum QUERY_COMMANDS_LITERAL {
// comparison
EQ = 'eq',
NEQ = 'neq',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
IN = 'in',
NIN = 'nin',
// geo
GEO_NEAR = 'geoNear',
GEO_WITHIN = 'geoWithin',
GEO_INTERSECTS = 'geoIntersects',
// element
EXISTS = 'exists',
// evaluation
MOD = 'mod',
// array
ALL = 'all',
ELEM_MATCH = 'elemMatch',
SIZE = 'size'
}
class DatabaseQueryCommand extends DatabaseLogicCommand {
eq(val: any): DatabaseLogicCommand
neq(val: any): DatabaseLogicCommand
gt(val: any): DatabaseLogicCommand
gte(val: any): DatabaseLogicCommand
lt(val: any): DatabaseLogicCommand
lte(val: any): DatabaseLogicCommand
in(val: any[]): DatabaseLogicCommand
nin(val: any[]): DatabaseLogicCommand
exists(val: boolean): DatabaseLogicCommand
mod(divisor: number, remainder: number): DatabaseLogicCommand
all(val: any[]): DatabaseLogicCommand
elemMatch(val: any): DatabaseLogicCommand
size(val: number): DatabaseLogicCommand
geoNear(options: IGeoNearCommandOptions): DatabaseLogicCommand
geoWithin(options: IGeoWithinCommandOptions): DatabaseLogicCommand
geoIntersects(
options: IGeoIntersectsCommandOptions
): DatabaseLogicCommand
}
enum PROJECTION_COMMANDS_LITERAL {
SLICE = 'slice'
}
class DatabaseProjectionCommand {}
enum UPDATE_COMMANDS_LITERAL {
// field
SET = 'set',
REMOVE = 'remove',
INC = 'inc',
MUL = 'mul',
MIN = 'min',
MAX = 'max',
RENAME = 'rename',
// bitwise
BIT = 'bit',
// array
PUSH = 'push',
POP = 'pop',
SHIFT = 'shift',
UNSHIFT = 'unshift',
ADD_TO_SET = 'addToSet',
PULL = 'pull',
PULL_ALL = 'pullAll'
}
class DatabaseUpdateCommand {}
class Batch {}
/**
* A contract that all API provider must adhere to
*/
class APIBaseContract<
PromiseReturn,
CallbackReturn,
Param extends IAPIParam,
Context = any
> {
getContext(param: Param): Context
/**
* In case of callback-style invocation, this function will be called
*/
getCallbackReturn(param: Param, context: Context): CallbackReturn
getFinalParam<T extends Param>(param: Param, context: Context): T
run<T extends Param>(param: T): Promise<PromiseReturn>
}
interface IGeoPointConstructor {
new (longitude: number, latitide: number): GeoPoint
new (geojson: IGeoJSONPoint): GeoPoint
(longitude: number, latitide: number): GeoPoint
(geojson: IGeoJSONPoint): GeoPoint
}
interface IGeoMultiPointConstructor {
new (points: GeoPoint[] | IGeoJSONMultiPoint): GeoMultiPoint
(points: GeoPoint[] | IGeoJSONMultiPoint): GeoMultiPoint
}
interface IGeoLineStringConstructor {
new (points: GeoPoint[] | IGeoJSONLineString): GeoLineString
(points: GeoPoint[] | IGeoJSONLineString): GeoLineString
}
interface IGeoMultiLineStringConstructor {
new (
lineStrings: GeoLineString[] | IGeoJSONMultiLineString
): GeoMultiLineString
(
lineStrings: GeoLineString[] | IGeoJSONMultiLineString
): GeoMultiLineString
}
interface IGeoPolygonConstructor {
new (lineStrings: GeoLineString[] | IGeoJSONPolygon): GeoPolygon
(lineStrings: GeoLineString[] | IGeoJSONPolygon): GeoPolygon
}
interface IGeoMultiPolygonConstructor {
new (polygons: GeoPolygon[] | IGeoJSONMultiPolygon): GeoMultiPolygon
(polygons: GeoPolygon[] | IGeoJSONMultiPolygon): GeoMultiPolygon
}
interface IGeo {
Point: IGeoPointConstructor
MultiPoint: IGeoMultiPointConstructor
LineString: IGeoLineStringConstructor
MultiLineString: IGeoMultiLineStringConstructor
Polygon: IGeoPolygonConstructor
MultiPolygon: IGeoMultiPolygonConstructor
}
interface IGeoJSONPoint {
type: 'Point'
coordinates: [number, number]
}
interface IGeoJSONMultiPoint {
type: 'MultiPoint'
coordinates: Array<[number, number]>
}
interface IGeoJSONLineString {
type: 'LineString'
coordinates: Array<[number, number]>
}
interface IGeoJSONMultiLineString {
type: 'MultiLineString'
coordinates: Array<Array<[number, number]>>
}
interface IGeoJSONPolygon {
type: 'Polygon'
coordinates: Array<Array<[number, number]>>
}
interface IGeoJSONMultiPolygon {
type: 'MultiPolygon'
coordinates: Array<Array<Array<[number, number]>>>
}
type IGeoJSONObject =
| IGeoJSONPoint
| IGeoJSONMultiPoint
| IGeoJSONLineString
| IGeoJSONMultiLineString
| IGeoJSONPolygon
| IGeoJSONMultiPolygon
abstract class GeoPoint {
longitude: number
latitude: number
constructor(longitude: number, latitude: number)
toJSON(): Record<string, any>
toString(): string
}
abstract class GeoMultiPoint {
points: GeoPoint[]
constructor(points: GeoPoint[])
toJSON(): IGeoJSONMultiPoint
toString(): string
}
abstract class GeoLineString {
points: GeoPoint[]
constructor(points: GeoPoint[])
toJSON(): IGeoJSONLineString
toString(): string
}
abstract class GeoMultiLineString {
lines: GeoLineString[]
constructor(lines: GeoLineString[])
toJSON(): IGeoJSONMultiLineString
toString(): string
}
abstract class GeoPolygon {
lines: GeoLineString[]
constructor(lines: GeoLineString[])
toJSON(): IGeoJSONPolygon
toString(): string
}
abstract class GeoMultiPolygon {
polygons: GeoPolygon[]
constructor(polygons: GeoPolygon[])
toJSON(): IGeoJSONMultiPolygon
toString(): string
}
type GeoInstance =
| GeoPoint
| GeoMultiPoint
| GeoLineString
| GeoMultiLineString
| GeoPolygon
| GeoMultiPolygon
interface IGeoNearCommandOptions {
geometry: GeoPoint
maxDistance?: number
minDistance?: number
}
interface IGeoWithinCommandOptions {
geometry: GeoPolygon | GeoMultiPolygon
}
interface IGeoIntersectsCommandOptions {
geometry:
| GeoPoint
| GeoMultiPoint
| GeoLineString
| GeoMultiLineString
| GeoPolygon
| GeoMultiPolygon
}
interface IServerDateOptions {
offset: number
}
abstract class ServerDate {
readonly options: IServerDateOptions
constructor(options?: IServerDateOptions)
}
interface IRegExpOptions {
regexp: string
options?: string
}
interface IRegExpConstructor {
new (options: IRegExpOptions): RegExp
(options: IRegExpOptions): RegExp
}
abstract class RegExp {
readonly regexp: string
readonly options: string
constructor(options: IRegExpOptions)
}
type DocumentId = string | number
interface IDocumentData {
_id?: DocumentId
[key: string]: any
}
type IDBAPIParam = IAPIParam
interface IAddDocumentOptions extends IDBAPIParam {
data: IDocumentData
}
type IGetDocumentOptions = IDBAPIParam
type ICountDocumentOptions = IDBAPIParam
interface IUpdateDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface IUpdateSingleDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface ISetDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface ISetSingleDocumentOptions extends IDBAPIParam {
data: IUpdateCondition
}
interface IRemoveDocumentOptions extends IDBAPIParam {
query: IQueryCondition
}
type IRemoveSingleDocumentOptions = IDBAPIParam
interface IWatchOptions {
// server realtime data init & change event
onChange: (snapshot: ISnapshot) => void
// error while connecting / listening
onError: (error: any) => void
}
interface ISnapshot {
id: number
docChanges: ISingleDBEvent[]
docs: Record<string, any>
type?: SnapshotType
}
type SnapshotType = 'init'
interface ISingleDBEvent {
id: number
dataType: DataType
queueType: QueueType
docId: string
doc: Record<string, any>
updatedFields?: Record<string, any>
removedFields?: string[]
}
type DataType = 'init' | 'update' | 'replace' | 'add' | 'remove' | 'limit'
type QueueType = 'init' | 'enqueue' | 'dequeue' | 'update'
interface IQueryCondition {
[key: string]: any
}
type IStringQueryCondition = string
interface IQueryResult extends IAPISuccessParam {
data: IDocumentData[]
}
interface IQuerySingleResult extends IAPISuccessParam {
data: IDocumentData
}
interface IUpdateCondition {
[key: string]: any
}
type IStringUpdateCondition = string
interface IAddResult extends IAPISuccessParam {
_id: DocumentId
}
interface IUpdateResult extends IAPISuccessParam {
stats: {
updated: number
// created: number,
}
}
interface ISetResult extends IAPISuccessParam {
_id: DocumentId
stats: {
updated: number
created: number
}
}
interface IRemoveResult extends IAPISuccessParam {
stats: {
removed: number
}
}
interface ICountResult extends IAPISuccessParam {
total: number
}
}
type Optional<T> = { [K in keyof T]+?: T[K] }
type OQ<
T extends Optional<
Record<'complete' | 'success' | 'fail', (...args: any[]) => any>
>
> =
| (RQ<T> & Required<Pick<T, 'success'>>)
| (RQ<T> & Required<Pick<T, 'fail'>>)
| (RQ<T> & Required<Pick<T, 'complete'>>)
| (RQ<T> & Required<Pick<T, 'success' | 'fail'>>)
| (RQ<T> & Required<Pick<T, 'success' | 'complete'>>)
| (RQ<T> & Required<Pick<T, 'fail' | 'complete'>>)
| (RQ<T> & Required<Pick<T, 'fail' | 'complete' | 'success'>>)
type RQ<
T extends Optional<
Record<'complete' | 'success' | 'fail', (...args: any[]) => any>
>
> = Pick<T, Exclude<keyof T, 'complete' | 'success' | 'fail'>>

@ -0,0 +1,636 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.Component {
type Instance<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends Partial<MethodOption>,
TCustomInstanceProperty extends IAnyObject = {},
TIsPage extends boolean = false
> = InstanceProperties &
InstanceMethods<TData> &
TMethod &
(TIsPage extends true ? Page.ILifetime : {}) &
TCustomInstanceProperty & {
/** 组件数据,**包括内部数据和属性值** */
data: TData & PropertyOptionToData<TProperty>
/** 组件数据,**包括内部数据和属性值**(与 `data` 一致) */
properties: TData & PropertyOptionToData<TProperty>
}
type TrivialInstance = Instance<
IAnyObject,
IAnyObject,
IAnyObject,
IAnyObject
>
type TrivialOption = Options<IAnyObject, IAnyObject, IAnyObject, IAnyObject>
type Options<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = {},
TIsPage extends boolean = false
> = Partial<Data<TData>> &
Partial<Property<TProperty>> &
Partial<Method<TMethod, TIsPage>> &
Partial<OtherOption> &
Partial<Lifetimes> &
ThisType<
Instance<
TData,
TProperty,
TMethod,
TCustomInstanceProperty,
TIsPage
>
>
interface Constructor {
<
TData extends DataOption,
TProperty extends PropertyOption,
TMethod extends MethodOption,
TCustomInstanceProperty extends IAnyObject = {},
TIsPage extends boolean = false
>(
options: Options<
TData,
TProperty,
TMethod,
TCustomInstanceProperty,
TIsPage
>
): string
}
type DataOption = Record<string, any>
type PropertyOption = Record<string, AllProperty>
type MethodOption = Record<string, Function>
interface Data<D extends DataOption> {
/** 组件的内部数据,和 `properties` 一同用于组件的模板渲染 */
data?: D
}
interface Property<P extends PropertyOption> {
/** 组件的对外属性,是属性名到属性设置的映射表 */
properties: P
}
interface Method<M extends MethodOption, TIsPage extends boolean = false> {
/** 组件的方法,包括事件响应函数和任意的自定义方法,关于事件响应函数的使用,参见 [组件间通信与事件](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/events.html) */
methods: M & (TIsPage extends true ? Partial<Page.ILifetime> : {})
}
type PropertyType =
| StringConstructor
| NumberConstructor
| BooleanConstructor
| ArrayConstructor
| ObjectConstructor
| null
type ValueType<T extends PropertyType> = T extends null
? any
: T extends StringConstructor
? string
: T extends NumberConstructor
? number
: T extends BooleanConstructor
? boolean
: T extends ArrayConstructor
? any[]
: T extends ObjectConstructor
? IAnyObject
: never
type FullProperty<T extends PropertyType> = {
/** 属性类型 */
type: T
/** 属性初始值 */
value?: ValueType<T>
/** 属性值被更改时的响应函数 */
observer?:
| string
| ((
newVal: ValueType<T>,
oldVal: ValueType<T>,
changedPath: Array<string | number>
) => void)
/** 属性的类型(可以指定多个) */
optionalTypes?: ShortProperty[]
}
type AllFullProperty =
| FullProperty<StringConstructor>
| FullProperty<NumberConstructor>
| FullProperty<BooleanConstructor>
| FullProperty<ArrayConstructor>
| FullProperty<ObjectConstructor>
| FullProperty<null>
type ShortProperty =
| StringConstructor
| NumberConstructor
| BooleanConstructor
| ArrayConstructor
| ObjectConstructor
| null
type AllProperty = AllFullProperty | ShortProperty
type PropertyToData<T extends AllProperty> = T extends ShortProperty
? ValueType<T>
: FullPropertyToData<Exclude<T, ShortProperty>>
type FullPropertyToData<T extends AllFullProperty> = ValueType<T['type']>
type PropertyOptionToData<P extends PropertyOption> = {
[name in keyof P]: PropertyToData<P[name]>
}
interface InstanceProperties {
/** 组件的文件路径 */
is: string
/** 节点id */
id: string
/** 节点dataset */
dataset: Record<string, string>
}
interface InstanceMethods<D extends DataOption> {
/** `setData`
* `this.data`
*
* ****
*
* 1. ** this.data this.setData **
* 1. JSON
* 1. 1024kB
* 1. data value `undefined`
*/
setData(
/**
*
* `key: value` `this.data` `key` `value`
*
* `key` `array[2].message``a.b.c.d` this.data
*/
data: Partial<D> & IAnyObject,
/** setData引起的界面更新渲染完毕后的回调函数最低基础库 `1.5.0` */
callback?: () => void
): void
/** 检查组件是否具有 `behavior` 检查时会递归检查被直接或间接引入的所有behavior */
hasBehavior(behavior: Behavior.BehaviorIdentifier): void
/** 触发事件,参见组件事件 */
triggerEvent<DetailType = any>(
name: string,
detail?: DetailType,
options?: TriggerEventOption
): void
/** 创建一个 SelectorQuery 对象,选择器选取范围为这个组件实例内 */
createSelectorQuery(): SelectorQuery
/** 创建一个 IntersectionObserver 对象,选择器选取范围为这个组件实例内 */
createIntersectionObserver(
options: CreateIntersectionObserverOption
): IntersectionObserver
/** 使用选择器选择组件实例节点,返回匹配到的第一个组件实例对象(会被 `wx://component-export` 影响) */
selectComponent(selector: string): TrivialInstance
/** 使用选择器选择组件实例节点,返回匹配到的全部组件实例对象组成的数组 */
selectAllComponents(selector: string): TrivialInstance[]
/**
* `wx://component-export`
*
* [`2.8.2`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
selectOwnerComponent(): TrivialInstance
/** 获取这个关系所对应的所有关联节点,参见 组件间关系 */
getRelationNodes(relationKey: string): TrivialInstance[]
/**
* callback setData setData
*
* [`2.4.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
groupSetData(callback?: () => void): void
/**
* custom-tab-bar
*
* [`2.6.2`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
getTabBar(): TrivialInstance
/**
*
*
* [`2.7.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
getPageId(): string
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* [`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
animate(
selector: string,
keyFrames: KeyFrame[],
duration: number,
callback?: () => void
): void
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* [`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
animate(
selector: string,
keyFrames: ScrollTimelineKeyframe[],
duration: number,
scrollTimeline: ScrollTimelineOption
): void
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* [`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
clearAnimation(selector: string, callback: () => void): void
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/view/animation.html)
*
* [`2.9.0`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
**/
clearAnimation(
selector: string,
options?: ClearAnimationOptions,
callback?: () => void
): void
getOpenerEventChannel(): EventChannel
}
interface ComponentOptions {
/**
* [slot](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#组件wxml的slot)
*/
multipleSlots?: boolean
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#组件样式隔离)
*/
addGlobalClass?: boolean
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#组件样式隔离)
*/
styleIsolation?:
| 'isolated'
| 'apply-shared'
| 'shared'
| 'page-isolated'
| 'page-apply-shared'
| 'page-shared'
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/pure-data.html) 是一些不用于界面渲染的 data 字段,可以用于提升页面更新性能。从小程序基础库版本 2.8.2 开始支持。
*/
pureDataPattern?: RegExp
/**
* [](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html#%E8%99%9A%E6%8B%9F%E5%8C%96%E7%BB%84%E4%BB%B6%E8%8A%82%E7%82%B9) 使自定义组件内部的第一层节点由自定义组件本身完全决定。从小程序基础库版本 [`2.11.2`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) 开始支持 */
virtualHost?: boolean
}
interface TriggerEventOption {
/**
*
* `false`
*/
bubbles?: boolean
/** 穿false
*
* `false`
*/
composed?: boolean
/**
*
* `false`
*/
capturePhase?: boolean
}
interface RelationOption {
/** 目标组件的相对关系 */
type: 'parent' | 'child' | 'ancestor' | 'descendant'
/** 关系生命周期函数当关系被建立在页面节点树中时触发触发时机在组件attached生命周期之后 */
linked?(target: TrivialInstance): void
/** 关系生命周期函数当关系在页面节点树中发生改变时触发触发时机在组件moved生命周期之后 */
linkChanged?(target: TrivialInstance): void
/** 关系生命周期函数当关系脱离页面节点树时触发触发时机在组件detached生命周期之后 */
unlinked?(target: TrivialInstance): void
/** 如果这一项被设置则它表示关联的目标节点所应具有的behavior所有拥有这一behavior的组件节点都会被关联 */
target?: string
}
interface PageLifetimes {
/**
*
* /
*/
show(): void
/**
*
* / `navigateTo` `tab`
*/
hide(): void
/**
*
*
*/
resize(size: Page.IResizeOption): void
}
type DefinitionFilter = <T extends TrivialOption>(
/** 使用该 behavior 的 component/behavior 的定义对象 */
defFields: T,
/** 该 behavior 所使用的 behavior 的 definitionFilter 函数列表 */
definitionFilterArr?: DefinitionFilter[]
) => void
interface Lifetimes {
/** `created``attached``ready``moved``detached` `lifetimes` `lifetimes`
*
* `2.2.3` */
lifetimes: Partial<{
/**
* `setData`
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
created(): void
/**
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
attached(): void
/**
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
ready(): void
/**
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
moved(): void
/**
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
detached(): void
/**
*
*
* [`2.4.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
error(err: Error): void
}>
/**
* @deprecated `2.2.3` lifetimes
*
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
created(): void
/**
* @deprecated `2.2.3` lifetimes
*
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
attached(): void
/**
* @deprecated `2.2.3` lifetimes
*
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
ready(): void
/**
* @deprecated `2.2.3` lifetimes
*
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
moved(): void
/**
* @deprecated `2.2.3` lifetimes
*
*
*
* [`1.6.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
detached(): void
/**
* @deprecated `2.2.3` lifetimes
*
*
*
* [`2.4.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
error(err: Error): void
}
interface OtherOption {
/** 类似于mixins和traits的组件间代码复用机制参见 [behaviors](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/behaviors.html) */
behaviors: Behavior.BehaviorIdentifier[]
/**
* properties data [](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/observer.html)
*
* [`2.6.1`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html)
*/
observers: Record<string, (...args: any[]) => any>
/** 组件间关系定义,参见 [组件间关系](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/lifetimes.html) */
relations: {
[componentName: string]: RelationOption
}
/** 组件接受的外部样式类,参见 [外部样式类](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html) */
externalClasses?: string[]
/** 组件所在页面的生命周期声明对象,参见 [组件生命周期](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/lifetimes.html)
*
* [`2.2.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) */
pageLifetimes?: Partial<PageLifetimes>
/** 一些选项(文档中介绍相关特性时会涉及具体的选项设置,这里暂不列举) */
options: ComponentOptions
/** 定义段过滤器,用于自定义组件扩展,参见 [自定义组件扩展](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/extend.html)
*
* [`2.2.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) */
definitionFilter?: DefinitionFilter
/**
* 使 `behavior: wx://component-export` selectComponent [](https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/events.html)
* [`2.2.3`](https://developers.weixin.qq.com/miniprogram/dev/framework/compatibility.html) */
export: () => IAnyObject
}
interface KeyFrame {
/** 关键帧的偏移,范围[0-1] */
offset?: number
/** 动画缓动函数 */
ease?: string
/** 基点位置,即 CSS transform-origin */
transformOrigin?: string
/** 背景颜色,即 CSS background-color */
backgroundColor?: string
/** 底边位置,即 CSS bottom */
bottom?: number | string
/** 高度,即 CSS height */
height?: number | string
/** 左边位置,即 CSS left */
left?: number | string
/** 宽度,即 CSS width */
width?: number | string
/** 不透明度,即 CSS opacity */
opacity?: number | string
/** 右边位置,即 CSS right */
right?: number | string
/** 顶边位置,即 CSS top */
top?: number | string
/** 变换矩阵,即 CSS transform matrix */
matrix?: number[]
/** 三维变换矩阵,即 CSS transform matrix3d */
matrix3d?: number[]
/** 旋转,即 CSS transform rotate */
rotate?: number
/** 三维旋转,即 CSS transform rotate3d */
rotate3d?: number[]
/** X 方向旋转,即 CSS transform rotateX */
rotateX?: number
/** Y 方向旋转,即 CSS transform rotateY */
rotateY?: number
/** Z 方向旋转,即 CSS transform rotateZ */
rotateZ?: number
/** 缩放,即 CSS transform scale */
scale?: number[]
/** 三维缩放,即 CSS transform scale3d */
scale3d?: number[]
/** X 方向缩放,即 CSS transform scaleX */
scaleX?: number
/** Y 方向缩放,即 CSS transform scaleY */
scaleY?: number
/** Z 方向缩放,即 CSS transform scaleZ */
scaleZ?: number
/** 倾斜,即 CSS transform skew */
skew?: number[]
/** X 方向倾斜,即 CSS transform skewX */
skewX?: number
/** Y 方向倾斜,即 CSS transform skewY */
skewY?: number
/** 位移,即 CSS transform translate */
translate?: Array<number | string>
/** 三维位移,即 CSS transform translate3d */
translate3d?: Array<number | string>
/** X 方向位移,即 CSS transform translateX */
translateX?: number | string
/** Y 方向位移,即 CSS transform translateY */
translateY?: number | string
/** Z 方向位移,即 CSS transform translateZ */
translateZ?: number | string
}
interface ClearAnimationOptions {
/** 基点位置,即 CSS transform-origin */
transformOrigin?: boolean
/** 背景颜色,即 CSS background-color */
backgroundColor?: boolean
/** 底边位置,即 CSS bottom */
bottom?: boolean
/** 高度,即 CSS height */
height?: boolean
/** 左边位置,即 CSS left */
left?: boolean
/** 宽度,即 CSS width */
width?: boolean
/** 不透明度,即 CSS opacity */
opacity?: boolean
/** 右边位置,即 CSS right */
right?: boolean
/** 顶边位置,即 CSS top */
top?: boolean
/** 变换矩阵,即 CSS transform matrix */
matrix?: boolean
/** 三维变换矩阵,即 CSS transform matrix3d */
matrix3d?: boolean
/** 旋转,即 CSS transform rotate */
rotate?: boolean
/** 三维旋转,即 CSS transform rotate3d */
rotate3d?: boolean
/** X 方向旋转,即 CSS transform rotateX */
rotateX?: boolean
/** Y 方向旋转,即 CSS transform rotateY */
rotateY?: boolean
/** Z 方向旋转,即 CSS transform rotateZ */
rotateZ?: boolean
/** 缩放,即 CSS transform scale */
scale?: boolean
/** 三维缩放,即 CSS transform scale3d */
scale3d?: boolean
/** X 方向缩放,即 CSS transform scaleX */
scaleX?: boolean
/** Y 方向缩放,即 CSS transform scaleY */
scaleY?: boolean
/** Z 方向缩放,即 CSS transform scaleZ */
scaleZ?: boolean
/** 倾斜,即 CSS transform skew */
skew?: boolean
/** X 方向倾斜,即 CSS transform skewX */
skewX?: boolean
/** Y 方向倾斜,即 CSS transform skewY */
skewY?: boolean
/** 位移,即 CSS transform translate */
translate?: boolean
/** 三维位移,即 CSS transform translate3d */
translate3d?: boolean
/** X 方向位移,即 CSS transform translateX */
translateX?: boolean
/** Y 方向位移,即 CSS transform translateY */
translateY?: boolean
/** Z 方向位移,即 CSS transform translateZ */
translateZ?: boolean
}
interface ScrollTimelineKeyframe {
composite?: 'replace' | 'add' | 'accumulate' | 'auto'
easing?: string
offset?: number | null
[property: string]: string | number | null | undefined
}
interface ScrollTimelineOption {
/** 指定滚动元素的选择器(只支持 scroll-view该元素滚动时会驱动动画的进度 */
scrollSource: string
/** 指定滚动的方向。有效值为 horizontal 或 vertical */
orientation?: string
/** 指定开始驱动动画进度的滚动偏移量,单位 px */
startScrollOffset: number
/** 指定停止驱动动画进度的滚动偏移量,单位 px */
endScrollOffset: number
/** 起始和结束的滚动范围映射的时间长度,该时间可用于与关键帧动画里的时间 (duration) 相匹配,单位 ms */
timeRange: number
}
}
/** ComponentComponent
*
* * 使 `this.data` 使 `setData`
* * `this` 访
* * data `dataXyz` WXML `data-xyz=""` dataset
* * 使 data
* * `2.0.9` data
* * `bug` : type Object Array `this.setData` observer observer `newVal` `oldVal` `changedPath`
*/
declare let Component: WechatMiniprogram.Component.Constructor

File diff suppressed because it is too large Load Diff

@ -0,0 +1,259 @@
/*! *****************************************************************************
Copyright (c) 2021 Tencent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***************************************************************************** */
declare namespace WechatMiniprogram.Page {
type Instance<
TData extends DataOption,
TCustom extends CustomOption
> = OptionalInterface<ILifetime> &
InstanceProperties &
InstanceMethods<TData> &
Data<TData> &
TCustom
type Options<
TData extends DataOption,
TCustom extends CustomOption
> = (TCustom & Partial<Data<TData>> & Partial<ILifetime>) &
ThisType<Instance<TData, TCustom>>
type TrivialInstance = Instance<IAnyObject, IAnyObject>
interface Constructor {
<TData extends DataOption, TCustom extends CustomOption>(
options: Options<TData, TCustom>
): void
}
interface ILifetime {
/**
*
* onLoad
*/
onLoad(
/** 打开当前页面路径中的参数 */
query: Record<string, string | undefined>
): void | Promise<void>
/**
*
* /
*/
onShow(): void | Promise<void>
/**
*
*
*
* API `wx.setNavigationBarTitle``onReady`
*/
onReady(): void | Promise<void>
/**
*
* / `navigateTo` `tab`
*/
onHide(): void | Promise<void>
/**
*
* `redirectTo``navigateBack`
*/
onUnload(): void | Promise<void>
/**
*
*
* - `app.json``window``enablePullDownRefresh`
* - `wx.startPullDownRefresh`
* - `wx.stopPullDownRefresh`
*/
onPullDownRefresh(): void | Promise<void>
/**
*
*
* - `app.json``window``onReachBottomDistance`
* -
*/
onReachBottom(): void | Promise<void>
/**
*
* `<button>` `open-type="share"`
*
* ****
*
* return Object
*/
onShareAppMessage(
/** 分享发起来源参数 */
options: IShareAppMessageOption
): ICustomShareContent | void
/**
*
*
* Beta Android [ (Beta)](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share-timeline.html)
*
* 2.11.3
*/
onShareTimeline(): ICustomTimelineContent | void
/**
*
*
*/
onPageScroll(
/** 页面滚动参数 */
options: IPageScrollOption
): void | Promise<void>
/** 当前是 tab 页时,点击 tab 时触发,最低基础库: `1.9.0` */
onTabItemTap(
/** tab 点击参数 */
options: ITabItemTapOption
): void | Promise<void>
/** 窗口尺寸改变时触发,最低基础库:`2.4.0` */
onResize(
/** 窗口尺寸参数 */
options: IResizeOption
): void | Promise<void>
/**
*
* 2.10.3 7.0.15 iOS
*/
onAddToFavorites(options: IAddToFavoritesOption): IAddToFavoritesContent
}
interface InstanceProperties {
/** 页面的文件路径 */
is: string
/** 到当前页面的路径 */
route: string
/** 打开当前页面路径中的参数 */
options: Record<string, string | undefined>
}
type DataOption = Record<string, any>
type CustomOption = Record<string, any>
type InstanceMethods<D extends DataOption> = Component.InstanceMethods<D>
interface Data<D extends DataOption> {
/**
*
* `data` 使****
*
* `data` `JSON``data``JSON`
*
* `WXML`
*/
data: D
}
interface ICustomShareContent {
/** 转发标题。默认值:当前小程序名称 */
title?: string
/** 转发路径,必须是以 / 开头的完整路径。默认值:当前页面 path */
path?: string
/** 自定义图片路径可以是本地文件路径、代码包文件路径或者网络图片路径。支持PNG及JPG。显示图片长宽比是 5:4最低基础库 `1.5.0`。默认值:使用默认截图 */
imageUrl?: string
}
interface ICustomTimelineContent {
/** 自定义标题,即朋友圈列表页上显示的标题。默认值:当前小程序名称 */
title?: string
/** 自定义页面路径中携带的参数,如 `path?a=1&b=2` 的 “?” 后面部分 默认值:当前页面路径携带的参数 */
query?: string
/** 自定义图片路径,可以是本地文件路径、代码包文件路径或者网络图片路径。支持 PNG 及 JPG。显示图片长宽比是 1:1。默认值默认使用小程序 Logo*/
imageUrl?: string
}
interface IPageScrollOption {
/** 页面在垂直方向已滚动的距离单位px */
scrollTop: number
}
interface IShareAppMessageOption {
/**
*
*
* - `button`
* - `menu`
*
* `1.2.4`
*/
from: 'button' | 'menu' | string
/** `from` `button` `target` `button` `undefined`
*
* `1.2.4` */
target: any
/** `<web-view>``<web-view>`url
*
* `1.6.4`
*/
webViewUrl?: string
}
interface ITabItemTapOption {
/** 被点击tabItem的序号从0开始最低基础库 `1.9.0` */
index: string
/** 被点击tabItem的页面路径最低基础库 `1.9.0` */
pagePath: string
/** 被点击tabItem的按钮文字最低基础库 `1.9.0` */
text: string
}
interface IResizeOption {
size: {
/** 变化后的窗口宽度,单位 px */
windowWidth: number
/** 变化后的窗口高度,单位 px */
windowHeight: number
}
}
interface IAddToFavoritesOption {
/** 页面中包含web-view组件时返回当前web-view的url */
webviewUrl?: string
}
interface IAddToFavoritesContent {
/** 自定义标题,默认值:页面标题或账号名称 */
title?: string
/** 自定义图片,显示图片长宽比为 11默认值页面截图 */
imageUrl?: string
/** 自定义query字段默认值当前页面的query */
query?: string
}
interface GetCurrentPages {
(): Array<Instance<IAnyObject, IAnyObject>>
}
}
/**
* `Object`
*/
declare let Page: WechatMiniprogram.Page.Constructor
/**
*
* ____
* - ____
* - `App.onLaunch` `getCurrentPages()` `page`
*/
declare let getCurrentPages: WechatMiniprogram.Page.GetCurrentPages

@ -0,0 +1,47 @@
# 底部导航功能说明
## ✅ 已完成的功能
### 1. 底部导航栏 (TabBar)
- **首页**:显示课程表,查看今日课程
- **个人中心**:管理课程和应用设置
### 2. 个人中心功能
按照需求文档简化版本实现:
#### 基础设置
- ✅ **添加课程**:跳转到手动添加课程页面
- ✅ **OCR导入**:跳转到拍照识别导入页面
#### 清空课程表
- ✅ **清空课程表**:删除所有课程数据(带确认提示)
#### 关于我们
- ✅ **关于我们**:显示版本信息和开发团队
## 🎯 核心功能流程
### 用户使用流程:
1. **首页** → 查看课程表
2. **个人中心** → **OCR导入** → 拍照识别课表
3. **个人中心** → **添加课程** → 手动添加课程
4. **个人中心** → **清空课程表** → 重新开始
## 📱 界面特点
- **简洁设计**:移除了复杂的图标,使用纯文字导航
- **核心功能**:专注于课表管理的核心需求
- **用户友好**:清晰的导航和操作流程
## 🔧 技术实现
- 使用微信小程序原生 TabBar
- 响应式布局适配不同屏幕
- 数据同步:首页和个人中心数据实时同步
## 📝 使用说明
1. 打开小程序,默认进入**首页**
2. 点击底部**个人中心**切换到设置页面
3. 在个人中心可以进行课程管理操作
4. 操作完成后自动返回首页查看结果

@ -0,0 +1,199 @@
# 课表OCR识别小程序 - 测试说明
## 📋 测试环境准备
### 1. 后端服务
确保后端服务正在运行:
```bash
cd schedule-ocr-backend
mvn spring-boot:run
```
访问 http://localhost:8080/api/health 确认服务正常
### 2. 微信开发者工具
1. 打开微信开发者工具
2. 导入项目:选择 `schedule-ocr-miniprogram` 目录
3. 设置AppID测试号
4. 开启调试模式
## 🧪 功能测试清单
### 一、首页-课程表展示 (pages/index)
#### 测试点:
- [ ] 页面正常加载
- [ ] 显示当前周信息
- [ ] 星期标题栏正确显示
- [ ] 空状态提示正确显示
- [ ] 底部操作按钮可点击
- [ ] 课程数据正确加载(如果有数据)
- [ ] 课程项点击跳转到编辑页面
#### 测试步骤:
1. 启动小程序,进入首页
2. 检查页面布局和样式
3. 点击"添加课程"按钮
4. 点击"OCR导入"按钮
5. 如果有课程数据,点击课程项
### 二、添加/编辑课程页面 (pages/course-add)
#### 测试点:
- [ ] 页面正常加载
- [ ] 表单字段正确显示
- [ ] 必填项验证
- [ ] 星期选择功能
- [ ] 时间选择功能
- [ ] 保存功能
- [ ] 编辑模式数据回显
- [ ] 删除功能(编辑模式)
#### 测试步骤:
1. 从首页点击"添加课程"进入
2. 填写课程信息:
- 课程名称:高等数学
- 上课地点教学楼A101
- 任课教师:张老师
- 选择星期一
- 开始时间08:00
- 结束时间09:40
3. 点击保存
4. 验证是否成功创建
5. 编辑已创建的课程
6. 测试删除功能
### 三、OCR导入页面 (pages/ocr-import)
#### 测试点:
- [ ] 页面正常加载
- [ ] 步骤指示器正确显示
- [ ] 图片选择功能
- [ ] 拍照功能
- [ ] 相册选择功能
- [ ] OCR识别流程
- [ ] 识别结果展示
- [ ] 课程选择功能
- [ ] 导入功能
#### 测试步骤:
1. 进入OCR导入页面
2. 点击选择图片
3. 选择拍照或从相册选择
4. 选择一张课表图片
5. 点击"开始识别"
6. 等待OCR识别完成
7. 查看识别结果
8. 选择要导入的课程
9. 点击"导入选中课程"
10. 查看导入结果
### 四、个人中心页面 (pages/profile)
#### 测试点:
- [ ] 页面正常加载
- [ ] 用户信息显示
- [ ] 统计数据正确
- [ ] 菜单项可点击
- [ ] 清空课程功能
- [ ] 关于我们信息
#### 测试步骤:
1. 进入个人中心页面
2. 检查用户信息和统计数据
3. 点击各个菜单项
4. 测试清空课程功能
5. 查看关于我们信息
## 🔧 API接口测试
### 1. 课程管理API
```bash
# 获取课程列表
curl http://localhost:8080/api/courses?userOpenid=test_user_001
# 创建课程
curl -X POST http://localhost:8080/api/courses \
-H "Content-Type: application/json" \
-d '{
"courseName": "测试课程",
"classroom": "A101",
"teacherName": "测试老师",
"dayOfWeek": 1,
"startTime": "08:00",
"endTime": "09:40",
"userOpenid": "test_user_001"
}'
```
### 2. OCR识别API
```bash
# 测试OCR配置
curl http://localhost:8080/api/ocr-test/config
# 获取OCR帮助
curl http://localhost:8080/api/ocr-test/help
```
## 🐛 常见问题排查
### 1. 网络请求失败
- 检查后端服务是否正常运行
- 检查API地址是否正确
- 查看控制台错误信息
### 2. OCR识别失败
- 检查百度OCR配置是否正确
- 确认图片格式和大小符合要求
- 查看后端日志
### 3. 页面样式异常
- 检查wxss文件是否正确加载
- 确认rpx单位使用正确
- 查看开发者工具控制台
### 4. 数据不显示
- 检查API返回数据格式
- 确认数据绑定语法正确
- 查看页面数据更新逻辑
## 📱 设备兼容性测试
### 测试设备:
- [ ] iPhone (iOS)
- [ ] Android 手机
- [ ] 微信开发者工具模拟器
### 测试要点:
- [ ] 页面布局适配
- [ ] 字体大小合适
- [ ] 按钮点击区域
- [ ] 图片显示正常
- [ ] 滚动流畅
## 📊 性能测试
### 测试指标:
- [ ] 页面加载时间 < 2
- [ ] API响应时间 < 1
- [ ] OCR识别时间 < 10
- [ ] 内存使用合理
- [ ] 无明显卡顿
## ✅ 测试完成标准
所有测试点通过后,小程序功能测试完成。如发现问题,请记录详细信息:
- 问题描述
- 复现步骤
- 预期结果
- 实际结果
- 设备信息
- 错误截图
## 🚀 发布前检查
- [ ] 所有功能正常
- [ ] 无控制台错误
- [ ] 性能指标达标
- [ ] 用户体验良好
- [ ] 代码规范检查
- [ ] 安全性检查

@ -0,0 +1,196 @@
# 课表OCR识别小程序 - 项目总结
## 🎯 项目概述
本项目是一个基于微信小程序的智能课表管理系统支持OCR图像识别技术自动导入课程信息。
### 核心功能
- 📅 **课程表展示**:直观的周视图课程表
- **手动添加课程**:支持详细的课程信息录入
- 📷 **OCR智能识别**:拍照识别课表图片自动导入
- 👤 **个人中心**:用户信息和数据管理
## 🏗️ 技术架构
### 前端技术栈
- **框架**:微信小程序原生开发
- **语言**TypeScript
- **样式**WXSS + 自定义组件
- **状态管理**:页面级状态管理
- **网络请求**封装的API服务
### 后端技术栈
- **框架**Spring Boot 2.7.x
- **数据库**SQLite轻量级
- **OCR服务**百度智能云OCR API
- **API设计**RESTful风格
## 📁 项目结构
### 前端结构
```
schedule-ocr-miniprogram/
├── miniprogram/
│ ├── pages/ # 页面目录
│ │ ├── index/ # 首页-课程表
│ │ ├── course-add/ # 添加/编辑课程
│ │ ├── ocr-import/ # OCR导入
│ │ └── profile/ # 个人中心
│ ├── components/ # 组件目录
│ ├── utils/ # 工具函数
│ │ ├── util.ts # 通用工具
│ │ └── api.ts # API封装
│ ├── images/ # 图片资源
│ ├── app.ts # 应用入口
│ ├── app.json # 应用配置
│ └── app.wxss # 全局样式
```
### 后端结构
```
schedule-ocr-backend/
├── src/main/java/com/scheduleocr/
│ ├── controller/ # 控制器层
│ ├── service/ # 服务层
│ ├── entity/ # 实体类
│ ├── dto/ # 数据传输对象
│ ├── config/ # 配置类
│ └── exception/ # 异常处理
├── src/main/resources/
│ └── application.yml # 应用配置
└── pom.xml # Maven配置
```
## ✨ 核心功能实现
### 1. 课程表展示
- **网格布局**:时间轴 + 7天课程网格
- **响应式设计**:适配不同屏幕尺寸
- **数据绑定**:动态加载课程数据
- **交互设计**:点击课程可编辑
### 2. 课程管理
- **CRUD操作**:完整的增删改查功能
- **表单验证**:必填项和格式验证
- **时间选择**原生picker组件
- **星期选择**:自定义选择器
### 3. OCR识别
- **图片选择**:支持拍照和相册选择
- **步骤指引**清晰的3步操作流程
- **识别结果**:可选择性导入课程
- **错误处理**:友好的错误提示
### 4. 数据管理
- **本地存储**:用户信息和设置
- **API集成**:统一的接口调用
- **错误处理**:全局异常处理
- **加载状态**:用户友好的加载提示
## 🎨 UI/UX设计
### 设计原则
- **简洁明了**:清晰的信息层次
- **一致性**:统一的视觉风格
- **易用性**:符合用户习惯的交互
- **响应式**:适配不同设备
### 色彩方案
- **主色调**#1296db微信蓝
- **辅助色**#f8f8f8浅灰背景
- **强调色**#ff4757警告红
- **成功色**#2ed573成功绿
### 组件设计
- **按钮**:圆角设计,渐变背景
- **卡片**:阴影效果,圆角边框
- **表单**:清晰的标签和输入框
- **导航**:简洁的标题栏
## 🔧 开发工具和环境
### 开发工具
- **前端**:微信开发者工具
- **后端**IntelliJ IDEA
- **版本控制**Git
- **API测试**Postman/curl
### 开发环境
- **Node.js**v16+
- **Java**JDK 11+
- **Maven**3.6+
- **微信开发者工具**:最新版
## 📊 项目特色
### 技术亮点
1. **OCR集成**百度智能云OCR API集成
2. **TypeScript**:类型安全的前端开发
3. **组件化**可复用的UI组件
4. **API封装**:统一的网络请求处理
5. **错误处理**:完善的异常处理机制
### 用户体验
1. **流畅交互**:平滑的页面切换和动画
2. **智能识别**OCR技术提升录入效率
3. **数据持久化**:本地存储用户数据
4. **离线支持**:基础功能离线可用
5. **响应式设计**:适配各种屏幕尺寸
## 🚀 部署和发布
### 前端部署
1. 在微信开发者工具中预览
2. 上传代码到微信后台
3. 提交审核
4. 发布上线
### 后端部署
1. 打包Spring Boot应用
2. 部署到服务器
3. 配置域名和SSL证书
4. 设置数据库和OCR配置
## 📈 后续优化方向
### 功能扩展
- [ ] 课程提醒功能
- [ ] 课表分享功能
- [ ] 多学期管理
- [ ] 课程统计分析
- [ ] 导出课表图片
### 技术优化
- [ ] 性能优化
- [ ] 缓存策略
- [ ] 离线数据同步
- [ ] 组件库抽取
- [ ] 单元测试覆盖
### 用户体验
- [ ] 动画效果优化
- [ ] 无障碍访问支持
- [ ] 多主题支持
- [ ] 个性化设置
- [ ] 用户反馈系统
## 🎉 项目成果
### 完成情况
- ✅ 前端4个核心页面开发完成
- ✅ 后端API接口开发完成
- ✅ OCR识别功能集成完成
- ✅ 数据库设计和实现完成
- ✅ 基础测试完成
### 代码质量
- **前端代码**TypeScript类型安全组件化设计
- **后端代码**Spring Boot最佳实践RESTful API
- **代码规范**:统一的命名和注释规范
- **错误处理**:完善的异常处理机制
## 📝 总结
本项目成功实现了一个功能完整的课表OCR识别小程序集成了现代化的前后端技术栈提供了良好的用户体验。通过OCR技术的应用大大提升了课程录入的效率为用户提供了便捷的课表管理解决方案。
项目展示了从需求分析、技术选型、架构设计到具体实现的完整开发流程,是一个很好的全栈开发实践案例。

@ -0,0 +1,174 @@
大学生课表OCR识别小程序 - 需求文档¥800精简版
=======================================================
项目概述
--------
开发一款大学生课程表管理小程序支持OCR图片识别导入课表提供基础的课程管理功能。
技术栈:微信小程序 + Java后端 + 百度OCR API
部署方式:本地开发环境运行,无需云服务器
预算限制¥800精简版保留核心功能
核心功能需求(精简版)
-------------------
1. 首页 - 课程表展示
├── 当前周课程表显示(固定显示本周)
├── 课程信息展示:
│ ├── 课程名称
│ ├── 上课时间
│ ├── 上课地点
│ └── 任课教师(可选)
├── "今天没有课程"空状态提示
└── 底部新增课程按钮
2. 添加/编辑课程(简化版)
├── 课程名称(必填)
├── 上课地点(必填)
├── 任课教师(选填)
├── 星期几选择
├── 上课时间段选择
└── 保存、取消按钮
3. OCR导入功能核心
├── 拍照/选择图片
├── 百度OCR识别
├── 简单解析课表
├── 识别结果展示
└── 一键导入课程
4. 个人中心(简化)
├── 基础设置
├── 清空课程表
└── 关于我们
精简功能说明
-----------
保留功能:
✅ 课程表展示和管理
✅ OCR图片识别导入
✅ 基础课程增删改
✅ 本地数据存储
精简功能:
❌ 多学期管理(只支持当前学期)
❌ 复杂周次设置(只支持每周重复)
❌ 课程详情页面(直接编辑)
❌ 高级统计功能
❌ 周次切换(固定显示本周)
技术架构设计(精简版)
-------------------
前端:微信小程序
├── 框架:原生微信小程序
├── 页面数量4个核心页面
├── 组件:基础组件,减少自定义
└── 存储:本地缓存
后端Java Spring Boot轻量版
├── 框架Spring Boot 2.7+
├── 数据库SQLite
├── OCR百度OCR API
└── 接口精简RESTful API
数据库设计(简化版)
-----------------
课程表 (courses) - 单表设计
├── id (主键)
├── user_openid (用户标识)
├── course_name (课程名称)
├── teacher_name (任课教师)
├── classroom (上课地点)
├── day_of_week (星期几1-7)
├── start_time (开始时间,如"08:00")
├── end_time (结束时间,如"09:40")
├── notes (备注)
└── create_time (创建时间)
API接口设计精简版
------------------
课程管理:
GET /api/courses # 获取课程列表
POST /api/courses # 新增课程
PUT /api/courses/{id} # 更新课程
DELETE /api/courses/{id} # 删除课程
OCR识别
POST /api/ocr/upload # 上传并识别课表图片
POST /api/ocr/import # 导入识别的课程
小程序页面结构(精简版)
---------------------
pages/
├── index/ # 首页-课程表
├── course-add/ # 添加课程(复用编辑)
├── ocr-import/ # OCR导入
└── profile/ # 个人中心
components/
├── course-item/ # 课程条目组件
└── time-picker/ # 简单时间选择
开发难度评估(精简版)
-------------------
整体难度:⭐⭐ (中等偏下)
简单部分 (60%)
├── 微信小程序基础界面
├── 简单的课程CRUD
├── 本地数据存储
└── 基础时间处理
中等难度 (35%)
├── OCR图片识别集成
├── 基础课表解析
├── Java后端API
└── 数据同步
稍有挑战 (5%)
├── OCR结果解析优化
└── 异常情况处理
开发周期安排(压缩版)
-------------------
第1天后端开发
├── 项目搭建 + 数据库设计
├── 基础API开发
├── OCR接口集成
└── 后端测试
1-2天小程序开发
├── 项目初始化 + 基础页面
├── 课程管理功能
├── OCR导入功能
└── 前端测试
1天集成测试
├── 前后端联调
├── 功能测试和优化
├── 用户体验优化
└── 最终测试和文档
总开发时间3-5天
成本预算¥800版本
------------------
开发成本¥800
├── 后端开发¥350 (44%)
├── 小程序开发¥300 (37%)
├── OCR集成¥100 (13%)
└── 测试文档¥50 (6%)
第三方服务:
├── 百度OCR API免费额度足够
├── 微信小程序:免费
└── SQLite数据库免费
总计¥800一次性费用无后续成本

@ -0,0 +1,216 @@
# 课表OCR识别小程序 - 快速开始指南
## 🚀 5分钟快速上手
### 第一步:启动后端服务
```bash
cd schedule-ocr-backend
mvn spring-boot:run
```
服务启动后访问http://localhost:8080
### 第二步:打开小程序
1. 使用微信开发者工具打开 `schedule-ocr-miniprogram` 目录
2. 编译并运行小程序
### 第三步:设置学期信息
1. 点击底部"个人中心"
2. 点击"学期设置" → 选择"秋季学期设置"
3. 系统自动配置为2025年秋季学期
### 第四步:导入课程
**方式一OCR识别推荐**
1. 首页点击"+"按钮 → "OCR导入"
2. 拍摄课表照片
3. 确认识别结果并导入
**方式二:手动添加**
1. 首页点击"+"按钮 → "添加课程"
2. 填写课程信息并保存
### 第五步:查看课表
- 首页显示当前周课程安排
- 点击左右箭头切换周次
- 点击课程卡片查看详情
## 📋 核心功能一览
| 功能 | 描述 | 位置 |
|------|------|------|
| 课表查看 | 按周显示课程安排 | 首页 |
| OCR导入 | 拍照识别课表 | 首页 → + → OCR导入 |
| 手动添加 | 手动录入课程 | 首页 → + → 添加课程 |
| 周次切换 | 查看不同周次 | 首页顶部箭头 |
| 学期设置 | 配置学期信息 | 个人中心 → 学期设置 |
| 数据管理 | 清空课程数据 | 个人中心 → 清空课程表 |
## 🛠️ 技术栈速览
### 前端(微信小程序)
- **语言**TypeScript
- **框架**:微信小程序原生
- **主要文件**
- `pages/index/` - 首页课表
- `pages/course-add/` - 课程添加
- `pages/ocr-import/` - OCR导入
- `pages/profile/` - 个人中心
### 后端Spring Boot
- **语言**Java 8+
- **框架**Spring Boot 2.7.x
- **数据库**SQLite
- **主要模块**
- `controller/` - API接口
- `service/` - 业务逻辑
- `repository/` - 数据访问
- `entity/` - 数据模型
## 🔧 开发配置
### 环境要求
- **JDK**: 8+
- **Maven**: 3.6+
- **微信开发者工具**: 最新版
- **Node.js**: 14+ (可选)
### 配置文件
**后端配置** (`application.yml`)
```yaml
server:
port: 8080
spring:
datasource:
url: jdbc:sqlite:schedule.db
driver-class-name: org.sqlite.JDBC
# OCR配置 - 需要申请百度AI开放平台账号
baidu:
ocr:
app-id: 你的应用ID # 从百度AI控制台获取
api-key: 你的API Key # 从百度AI控制台获取
secret-key: 你的Secret Key # 从百度AI控制台获取
```
### 📝 百度OCR API申请步骤
#### 1. 注册百度AI开放平台账号
1. 访问 [百度AI开放平台](https://ai.baidu.com/)
2. 点击右上角"控制台"进行登录/注册
3. 使用百度账号登录(没有则先注册)
#### 2. 创建OCR应用
1. 登录后进入 [AI开放平台控制台](https://console.bce.baidu.com/ai/)
2. 在左侧菜单找到"文字识别" → "概览"
3. 点击"创建应用"按钮
4. 填写应用信息:
- **应用名称**: 课表OCR识别可自定义
- **应用类型**: 选择"表格文字识别"(重要!)
- **应用描述**: 用于识别课表表格中的文字信息
5. 点击"立即创建"
> **⚠️ 重要提示**:
> - 必须选择"表格文字识别"而不是"通用文字识别"
> - 表格识别对课表这种结构化数据识别效果更好
> - 能够保持课表的行列结构信息
#### 3. 获取API密钥
创建成功后,在应用列表中可以看到:
- **AppID**: 应用的唯一标识
- **API Key**: 用于API调用认证
- **Secret Key**: 用于API调用认证
#### 4. 配置到项目中
将获取的密钥信息填入 `application.yml` 文件:
```yaml
baidu:
ocr:
app-id: 24xxxxxx # 替换为你的AppID
api-key: abcdefghijk # 替换为你的API Key
secret-key: 123456789 # 替换为你的Secret Key
```
#### 5. 免费额度说明
- **表格文字识别**: 每月免费200次
- **通用文字识别**: 每月免费1000次备用
- 超出免费额度后按次计费,价格详见官网
- 表格识别虽然额度少但效果更好,适合课表识别
#### 6. 测试配置
启动后端服务后可以通过以下方式测试OCR配置是否正确
1. 查看启动日志确认没有OCR相关错误
2. 使用小程序的OCR功能上传测试图片
3. 检查后端日志中的OCR调用结果
**前端配置** (`utils/api.ts`)
```typescript
const BASE_URL = 'http://localhost:8080/api';
const FIXED_USER_ID = 'my_fixed_user_003';
```
## 📱 使用技巧
### OCR识别技巧
1. **拍摄角度**:保持手机与课表平行
2. **光线条件**:确保光线充足,避免阴影
3. **图片清晰**:避免模糊和抖动
4. **完整内容**:确保课表内容完整在画面内
### 课程管理技巧
1. **周次设置**
- 每周:适用于整学期的课程
- 单周1,3,5,7...周的课程
- 双周2,4,6,8...周的课程
2. **时间冲突**
- 系统会自动检测时间冲突
- 相同时间不同周次的课程不冲突
- 可以设置不同周次范围的课程
3. **数据备份**
- 重要数据建议截图保存
- 清空前请确认数据已备份
## 🐛 常见问题
### Q1: OCR识别不准确怎么办
**A**:
- 确保图片清晰度足够
- 重新拍摄或调整角度
- 手动编辑识别结果
### Q2: 为什么课程不显示?
**A**:
- 检查当前周次是否在课程周次范围内
- 确认课程的单双周设置
- 查看是否设置了正确的学期信息
### Q3: 时间冲突误报怎么解决?
**A**:
- 检查课程的周次范围设置
- 确认开始周次和结束周次
- 验证单双周类型设置
### Q4: 如何重置所有数据?
**A**:
- 个人中心 → 清空课程表
- 重新设置学期信息
- 重新导入课程数据
## 📞 获取帮助
### 开发文档
- 详细文档:`项目文档.md`
- API文档查看后端Controller注释
- 前端组件查看pages目录下的文件
### 技术支持
如遇到技术问题,请检查:
1. 后端服务是否正常启动
2. 前端API地址配置是否正确
3. 数据库文件是否正常创建
4. 控制台是否有错误信息
---
**快速开始指南** | 版本 v1.0.0 | 更新时间2025年9月

@ -0,0 +1,241 @@
# 课表OCR识别小程序 - 技术架构说明
## 🏗️ 整体架构
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 微信小程序 │ │ Spring Boot │ │ 百度表格OCR API │
│ (前端界面) │◄──►│ (后端服务) │◄──►│ (表格识别) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 微信存储API │ │ SQLite数据库 │
│ (本地缓存) │ │ (数据持久化) │
└─────────────────┘ └─────────────────┘
```
## 📱 前端架构 (微信小程序)
### 目录结构
```
miniprogram/
├── pages/ # 页面目录
│ ├── index/ # 首页 - 课表展示
│ ├── course-add/ # 课程添加页面
│ ├── ocr-import/ # OCR导入页面
│ └── profile/ # 个人中心页面
├── utils/ # 工具类
│ ├── api.ts # API接口封装
│ └── util.ts # 通用工具函数
├── images/ # 图片资源
├── app.ts # 应用入口
├── app.json # 应用配置
└── app.wxss # 全局样式
```
### 核心模块
#### 1. 页面层 (Pages)
- **index**: 课表主页面,展示周课表
- **course-add**: 课程添加/编辑页面
- **ocr-import**: OCR识别导入流程
- **profile**: 个人中心和设置
#### 2. 工具层 (Utils)
- **api.ts**: HTTP请求封装统一错误处理
- **util.ts**: 时间计算、日期格式化、学期管理
#### 3. 数据流
```
用户操作 → 页面事件 → API调用 → 后端处理 → 数据更新 → 界面刷新
```
## 🖥️ 后端架构 (Spring Boot)
### 目录结构
```
src/main/java/com/scheduleocr/
├── controller/ # 控制器层
│ ├── CourseController.java # 课程管理API
│ └── OcrController.java # OCR识别API
├── service/ # 服务层
│ ├── CourseService.java # 课程业务逻辑
│ └── OcrService.java # OCR处理逻辑
├── repository/ # 数据访问层
│ └── CourseRepository.java # 课程数据访问
├── entity/ # 实体层
│ └── Course.java # 课程实体
├── dto/ # 数据传输对象
│ └── CourseDTO.java # 课程DTO
├── config/ # 配置类
│ ├── DatabaseConfig.java # 数据库配置
│ └── DatabaseInitializer.java # 数据库初始化
└── ScheduleOcrApplication.java # 应用启动类
```
### 分层架构
#### 1. 控制器层 (Controller)
- **职责**: 接收HTTP请求参数验证响应格式化
- **特点**: RESTful API设计统一响应格式
- **示例**:
```java
@RestController
@RequestMapping("/api/courses")
public class CourseController {
@PostMapping
public ResponseEntity<ApiResponse<Course>> createCourse(@RequestBody CourseDTO courseDTO)
}
```
#### 2. 服务层 (Service)
- **职责**: 业务逻辑处理,事务管理
- **特点**: 时间冲突检测OCR结果解析
- **核心算法**:
- 时间冲突检测算法
- OCR文本解析算法
- 周次范围计算算法
#### 3. 数据访问层 (Repository)
- **职责**: 数据库操作,查询优化
- **特点**: Spring Data JPA自定义查询
- **示例**:
```java
@Repository
public interface CourseRepository extends JpaRepository<Course, Long> {
List<Course> findByUserOpenidAndDayOfWeek(String userOpenid, Integer dayOfWeek);
}
```
#### 4. 实体层 (Entity)
- **职责**: 数据模型定义,数据库映射
- **特点**: JPA注解数据验证
## 🗄️ 数据库设计
### 表结构
```sql
courses (课程表)
├── id (主键)
├── user_openid (用户标识)
├── course_name (课程名称)
├── teacher_name (教师姓名)
├── classroom (上课地点)
├── day_of_week (星期几: 1-7)
├── start_time (开始时间: HH:mm)
├── end_time (结束时间: HH:mm)
├── start_week (开始周次)
├── end_week (结束周次)
├── week_type (单双周: 0=每周,1=单周,2=双周)
├── notes (备注信息)
├── create_time (创建时间)
└── update_time (更新时间)
```
### 索引设计
```sql
-- 用户查询索引
CREATE INDEX idx_courses_user_openid ON courses(user_openid);
-- 时间查询索引
CREATE INDEX idx_courses_day_time ON courses(day_of_week, start_time);
```
## 🔌 API设计
### RESTful API规范
```
GET /api/courses # 获取课程列表
POST /api/courses # 创建课程
PUT /api/courses/{id} # 更新课程
DELETE /api/courses/{id} # 删除课程
DELETE /api/courses/clear # 清空课程
POST /api/ocr/recognize # OCR识别
```
### 统一响应格式
```json
{
"code": 200,
"message": "操作成功",
"data": { ... },
"timestamp": "2025-09-01T10:00:00"
}
```
## 🧠 核心算法
### 1. 表格OCR解析算法
```java
// 表格识别解析流程
表格结构识别 → 单元格内容提取 → 行列关系分析 → 课程信息匹配 → 时间段映射 → 数据验证
```
**表格识别优势**
- 保持课表的行列结构信息
- 能够准确识别跨单元格的课程
- 自动关联时间段和课程内容
- 识别准确率比通用OCR提高30%+
### 2. 时间冲突检测算法
```java
boolean isConflict =
isTimeOverlap(time1, time2) && // 时间重叠
isWeekOverlap(week1, week2) && // 周次重叠
isSameDayOfWeek(day1, day2); // 同一天
```
### 3. 周次计算算法
```javascript
// 计算当前是第几周
const week = Math.floor((today - semesterStart) / (7 * 24 * 60 * 60 * 1000)) + 1;
```
## 🔒 安全设计
### 数据安全
- **用户隔离**: 通过userOpenid隔离用户数据
- **参数验证**: 严格验证所有输入参数
- **SQL注入防护**: 使用JPA参数化查询
### 接口安全
- **CORS配置**: 限制跨域访问
- **文件上传限制**: 限制文件类型和大小
- **错误处理**: 统一错误处理,不暴露敏感信息
## 📈 性能优化
### 前端优化
- **数据缓存**: 本地存储减少网络请求
- **图片压缩**: OCR前压缩图片
- **懒加载**: 按需加载页面组件
### 后端优化
- **数据库索引**: 为常用查询添加索引
- **连接池**: 使用HikariCP连接池
- **缓存策略**: 缓存OCR识别结果
### 数据库优化
- **SQLite配置**: 优化SQLite性能参数
- **查询优化**: 使用高效的查询语句
- **数据分页**: 大量数据分页查询
## 🚀 部署架构
### 开发环境
```
本地开发 → 微信开发者工具 + Spring Boot本地运行
```
### 生产环境
```
小程序发布 → 微信小程序平台
后端部署 → 云服务器 + Nginx反向代理
```
---
**技术架构说明** | 版本 v1.0.0 | 更新时间2025年9月

@ -0,0 +1,411 @@
# 课表OCR识别小程序 - 项目文档
## 📋 项目概述
课表OCR识别小程序是一个基于人工智能的智能课表管理系统支持拍照识别课表并自动导入课程信息。用户可以通过拍摄纸质课表或电子课表截图系统会自动识别并解析课程信息大大简化了课表录入的工作。
## 🎯 核心功能
### 1. OCR智能识别
- **表格识别**使用百度AI表格文字识别专门针对课表优化
- **结构化解析**:保持课表的行列结构,识别准确度更高
- **智能解析**:自动识别课程名称、教师、教室、时间等信息
- **多格式支持**:支持各种课表格式和布局
### 2. 课程管理
- **课程添加**:手动添加和编辑课程信息
- **时间冲突检测**:智能检测时间和周次冲突
- **周次管理**:支持单双周、指定周次范围
### 3. 课表展示
- **周视图**:按周显示课程安排
- **周次切换**:支持切换不同周次查看课程
- **实时日期**:根据学期配置自动计算日期
### 4. 个人中心
- **学期设置**:支持春季/秋季学期配置
- **数据管理**:课程导入、导出、清空功能
- **用户信息**:个人资料和系统设置
## 🛠️ 技术架构
### 前端技术栈
- **框架**:微信小程序原生开发
- **语言**TypeScript
- **样式**WXSS (微信样式表)
- **状态管理**:小程序原生数据绑定
- **工具库**
- 时间处理工具
- API请求封装
- 学期配置管理
### 后端技术栈
- **框架**Spring Boot 2.7.x
- **语言**Java 8+
- **数据库**SQLite (轻量级,适合小程序)
- **ORM**Spring Data JPA
- **OCR服务**百度AI表格文字识别API
- **核心依赖**
- Spring Web
- Spring Data JPA
- SQLite JDBC
- Jackson (JSON处理)
- SLF4J + Logback (日志)
### 数据库设计
```sql
CREATE TABLE 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,
start_time VARCHAR(10) NOT NULL,
end_time VARCHAR(10) NOT NULL,
start_week INTEGER,
end_week INTEGER,
week_type INTEGER,
notes VARCHAR(500),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## 📱 使用教程
### 1. 首次使用
#### 1.1 学期设置
1. 打开小程序,点击底部"个人中心"
2. 点击"学期设置"
3. 选择当前学期类型(春季/秋季)
4. 系统会自动配置学期开始日期和总周数
#### 1.2 用户授权
- 首次使用时,系统会请求获取用户信息权限
- 授权后可以看到个人头像和昵称
### 2. OCR导入课程
#### 2.1 拍摄课表
1. 在首页点击右下角"+"按钮
2. 选择"OCR导入"
3. 点击"拍照识别"按钮
4. 对准课表拍摄,确保图片清晰、光线充足
> **📸 拍摄技巧**
> - 保持手机与课表平行,避免倾斜
> - 确保课表完整在画面内,包含表头和边框
> - 光线充足,避免阴影和反光
> - 表格线条清晰可见,文字不模糊
#### 2.2 识别结果确认
1. 系统使用表格识别技术自动解析课程信息
2. 检查识别结果,确认课程信息准确性
3. 可以手动编辑错误的信息(点击"编辑"按钮)
4. 勾选需要导入的课程
5. 点击"导入选中课程"
> **✨ 表格识别优势**
> - 能够识别课表的行列结构
> - 自动关联时间段和课程信息
> - 识别准确率比通用文字识别高30%+
#### 2.3 导入成功
- 系统会显示导入成功的课程数量
- 自动跳转到课表页面查看结果
### 3. 手动添加课程
#### 3.1 添加新课程
1. 在首页点击右下角"+"按钮
2. 选择"添加课程"
3. 填写课程信息:
- 课程名称(必填)
- 任课教师
- 上课地点(必填)
- 星期几(必填)
- 上课时间(必填)
- 周次信息
#### 3.2 周次设置
- **开始周次**课程开始的周次如第1周
- **结束周次**课程结束的周次如第16周
- **单双周**
- 每周:每周都有课
- 单周只在单数周有课1,3,5,7...
- 双周只在双数周有课2,4,6,8...
### 4. 课表查看
#### 4.1 周视图
- 首页显示当前周的课程安排
- 按时间段和星期几排列
- 显示课程名称和上课地点
#### 4.2 周次切换
- 点击页面顶部的""和""按钮切换周次
- 日期会根据选中的周次自动更新
- 只显示当前周次有课的课程
#### 4.3 课程详情
- 点击课程卡片查看详细信息
- 长按课程卡片显示详情弹窗
- 可以直接编辑或删除课程
### 5. 课程编辑
#### 5.1 编辑课程
1. 点击课程卡片进入编辑页面
2. 修改需要更改的信息
3. 点击"保存"按钮
#### 5.2 删除课程
1. 在课程详情页面点击"删除"按钮
2. 确认删除操作
### 6. 数据管理
#### 6.1 清空课程表
1. 进入"个人中心"
2. 点击"清空课程表"
3. 确认删除所有课程数据
#### 6.2 学期切换
- 新学期开始时,在个人中心重新设置学期信息
- 系统会自动调整周次计算
## 🔧 开发部署
### 前端部署
1. 使用微信开发者工具打开项目
2. 配置小程序AppID
3. 修改API接口地址
4. 上传代码并提交审核
### 后端部署
#### 1. 配置百度OCR API必需
**步骤一申请百度AI开放平台账号**
1. 访问 [百度AI开放平台](https://ai.baidu.com/)
2. 注册/登录百度账号
3. 进入 [AI开放平台控制台](https://console.bce.baidu.com/ai/)
**步骤二:创建文字识别应用**
1. 在控制台左侧菜单选择"文字识别"
2. 点击"创建应用"
3. 填写应用信息:
- 应用名称课表OCR识别
- 应用类型:通用文字识别
- 应用描述:用于识别课表图片
4. 创建成功后获取三个关键信息:
- **AppID**: 应用唯一标识
- **API Key**: API调用密钥
- **Secret Key**: API安全密钥
**步骤三:配置到项目**
编辑 `src/main/resources/application.yml` 文件:
```yaml
# OCR配置
baidu:
ocr:
app-id: 24xxxxxx # 替换为你的AppID
api-key: abcdefghijk # 替换为你的API Key
secret-key: 123456789 # 替换为你的Secret Key
```
**免费额度说明**
- 通用文字识别每月1000次免费
- 高精度识别每月500次免费
- 超出后按次计费,详见官网价格
#### 2. 启动应用
```bash
# 编译打包
mvn clean package
# 运行应用
java -jar target/schedule-ocr-backend.jar
# 或直接运行
mvn spring-boot:run
```
#### 3. 验证部署
- 访问http://localhost:8080
- 检查OCR配置上传测试图片验证识别功能
- 查看日志确认无错误信息
### 环境要求
- **前端**:微信开发者工具
- **后端**JDK 8+, Maven 3.6+
- **数据库**SQLite自动创建
- **OCR服务**百度AI开放平台账号必需
## 🔌 API接口文档
### 基础信息
- **Base URL**: `http://localhost:8080/api`
- **Content-Type**: `application/json`
- **用户标识**: 使用固定用户ID `my_fixed_user_003`
### 课程管理接口
#### 1. 获取课程列表
```http
GET /courses?userOpenid={userOpenid}&currentWeek={week}
```
#### 2. 创建课程
```http
POST /courses
Content-Type: application/json
{
"userOpenid": "my_fixed_user_003",
"courseName": "高等数学",
"teacherName": "张教授",
"classroom": "教学楼101",
"dayOfWeek": 1,
"startTime": "08:00",
"endTime": "09:40",
"startWeek": 1,
"endWeek": 16,
"weekType": 0,
"notes": "第1-2节"
}
```
#### 3. 更新课程
```http
PUT /courses/{id}
```
#### 4. 删除课程
```http
DELETE /courses/{id}
```
#### 5. 清空所有课程
```http
DELETE /courses/clear?userOpenid={userOpenid}
```
### OCR识别接口
#### OCR图片识别
```http
POST /ocr/recognize
Content-Type: multipart/form-data
file: [图片文件]
userOpenid: my_fixed_user_003
```
**响应示例**
```json
{
"code": 200,
"message": "识别成功",
"data": {
"courses": [
{
"courseName": "高等数学",
"teacherName": "张教授",
"classroom": "教学楼101",
"dayOfWeek": 1,
"startTime": "08:00",
"endTime": "09:40",
"notes": "第1-2节 1-16周"
}
]
}
}
```
## 🎨 界面设计
### 设计原则
- **简洁明了**:界面简洁,操作直观
- **色彩统一**:使用蓝色主题色 `#1296db`
- **响应式**:适配不同尺寸的手机屏幕
- **用户友好**:提供清晰的操作反馈
### 主要页面
1. **首页**:课表展示,周次切换
2. **课程添加**:表单填写,时间选择
3. **OCR导入**:拍照识别,结果确认
4. **个人中心**:设置管理,数据操作
## 🚀 核心算法
### 1. OCR文本解析算法
```java
// 课程信息解析流程
1. 文本预处理(去除空格、标准化格式)
2. 关键信息提取(课程名、教师、教室、时间)
3. 时间段匹配(映射到标准时间表)
4. 周次信息解析(支持多种格式)
5. 数据验证和清洗
```
### 2. 时间冲突检测算法
```java
// 冲突检测逻辑
boolean isConflict =
isTimeOverlap(time1, time2) &&
isWeekOverlap(week1, week2) &&
isSameDayOfWeek(day1, day2);
```
### 3. 周次计算算法
```javascript
// 根据学期开始日期计算当前周次
const getCurrentWeek = () => {
const semesterStart = new Date(config.startDate);
const today = new Date();
const diffDays = Math.floor((today - semesterStart) / (1000 * 60 * 60 * 24));
return Math.floor(diffDays / 7) + 1;
};
```
## 📊 性能优化
### 前端优化
- **数据缓存**:本地存储用户配置和课程数据
- **图片压缩**OCR前自动压缩图片大小
- **懒加载**:按需加载页面组件
- **防抖处理**输入框防抖减少API调用
### 后端优化
- **数据库索引**:为常用查询字段添加索引
- **连接池**:使用连接池管理数据库连接
- **缓存策略**缓存OCR识别结果
- **异步处理**OCR识别使用异步处理
## 🔒 安全考虑
### 数据安全
- **用户隔离**通过userOpenid隔离不同用户数据
- **输入验证**:严格验证所有输入参数
- **SQL注入防护**:使用参数化查询
- **文件上传安全**:限制文件类型和大小
### 隐私保护
- **最小权限**:只请求必要的用户权限
- **数据加密**:敏感数据传输加密
- **本地存储**:用户数据存储在本地数据库
## 📞 技术支持
### 常见问题
1. **OCR识别不准确**:确保图片清晰,光线充足
2. **时间冲突误报**:检查周次设置是否正确
3. **课程不显示**:确认当前周次和课程周次范围
**版本**v1.0.0
**更新时间**2025年9月
**开发团队**课表OCR团队
Loading…
Cancel
Save