commit a20550422fd831a07deb56227ea9203ac47defd8 Author: irony Date: Sat Dec 6 18:19:46 2025 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ca58aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Maven +target/ +!target/*.jar + +# IDE +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# Git +.git/ +.gitignore + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b534a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +target/ + +*.tar diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..c2a3272 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,158 @@ +# Docker 部署指南 + +## 前置要求 + +- NAS 上已安装 Docker 和 Docker Compose +- MongoDB 数据库已运行并可访问 +- NAS 能访问 MongoDB 所在网络 + +## 部署步骤 + +### 方式一:在NAS上直接构建(推荐) + +1. **上传项目到NAS** + + 将整个 `server` 文件夹上传到NAS,例如:`/volume1/docker/novel-reader/` + +2. **修改配置** + + 编辑 `docker-compose.yml`,修改 MongoDB 连接地址: + ```yaml + environment: + - SPRING_DATA_MONGODB_URI=mongodb://你的MongoDB地址:端口/数据库名 + ``` + +3. **构建并启动** + + SSH 连接到 NAS,执行: + ```bash + cd /volume1/docker/novel-reader + docker-compose up -d --build + ``` + +4. **查看日志** + ```bash + docker-compose logs -f + ``` + +### 方式二:本地构建镜像后上传 + +1. **本地构建镜像** + ```bash + cd server + docker build -t novel-reader:latest . + ``` + +2. **导出镜像** + ```bash + docker save novel-reader:latest > novel-reader.tar + ``` + +3. **上传到NAS并导入** + ```bash + # 在NAS上执行 + docker load < novel-reader.tar + ``` + +4. **修改 docker-compose.yml** + + 注释掉 `build: .`,启用 `image:`: + ```yaml + services: + novel-reader: + # build: . + image: novel-reader:latest + ``` + +5. **启动服务** + ```bash + docker-compose up -d + ``` + +## 常用命令 + +```bash +# 启动服务 +docker-compose up -d + +# 停止服务 +docker-compose down + +# 查看日志 +docker-compose logs -f + +# 重启服务 +docker-compose restart + +# 重新构建并启动 +docker-compose up -d --build + +# 查看容器状态 +docker-compose ps +``` + +## 访问应用 + +启动成功后,访问:`http://NAS的IP地址:8080` + +在 iPhone 上: +1. 用 Safari 打开上述地址 +2. 点击分享按钮 → "添加到主屏幕" +3. 即可像原生App一样使用 + +## 配置说明 + +### 环境变量 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| SPRING_DATA_MONGODB_URI | MongoDB连接地址 | mongodb://192.168.18.100:38403/novel | +| JAVA_OPTS | JVM参数 | -Xms128m -Xmx256m | +| TZ | 时区 | Asia/Shanghai | + +### 内存调优 + +根据 NAS 内存大小调整 `JAVA_OPTS`: + +- 1GB 可用内存:`-Xms128m -Xmx256m` +- 2GB 可用内存:`-Xms256m -Xmx512m` +- 4GB+ 可用内存:`-Xms512m -Xmx1024m` + +### 端口修改 + +如需修改端口,编辑 `docker-compose.yml`: +```yaml +ports: + - "你想要的端口:8080" +``` + +## 故障排除 + +### 1. 容器启动失败 +```bash +# 查看详细日志 +docker-compose logs novel-reader +``` + +### 2. 无法连接 MongoDB +- 检查 MongoDB 地址是否正确 +- 检查 NAS 与 MongoDB 网络是否互通 +- 如果 MongoDB 也在 Docker 中,考虑使用 Docker 网络 + +### 3. 内存不足 +减小 JVM 内存配置,或增加 NAS 可用内存 + +### 4. 健康检查失败 +```bash +# 禁用健康检查(临时) +# 在 docker-compose.yml 中注释掉 healthcheck 部分 +``` + +## 更新应用 + +```bash +# 拉取最新代码后 +docker-compose down +docker-compose up -d --build +``` + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5fbc1c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# 多阶段构建 - 构建阶段 +FROM maven:3.8-eclipse-temurin-8 AS builder + +WORKDIR /app + +# 先复制pom.xml,利用Docker缓存下载依赖 +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# 复制源代码并构建 +COPY src ./src +RUN mvn package -DskipTests -B + +# 运行阶段 - 使用Eclipse Temurin JRE镜像 +FROM eclipse-temurin:8-jre + +WORKDIR /app + +# 设置时区为中国 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 从构建阶段复制jar包 +COPY --from=builder /app/target/*.jar app.jar + +# 暴露端口 +EXPOSE 8080 + +# JVM优化参数(适合小内存设备如NAS) +ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseG1GC -XX:MaxGCPauseMillis=100" + +# 启动命令 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/Dockerfile.china b/Dockerfile.china new file mode 100644 index 0000000..f3baabf --- /dev/null +++ b/Dockerfile.china @@ -0,0 +1,50 @@ +# 国内镜像版本 - 使用阿里云镜像源 +# 使用方法: docker build -f Dockerfile.china -t novel-reader:latest . + +# 多阶段构建 - 构建阶段 +FROM registry.cn-hangzhou.aliyuncs.com/acs/maven:3-jdk-8 AS builder + +WORKDIR /app + +# 配置Maven使用阿里云仓库 +RUN mkdir -p /root/.m2 +RUN echo '\n\ +\n\ + \n\ + \n\ + aliyun\n\ + central\n\ + https://maven.aliyun.com/repository/public\n\ + \n\ + \n\ +' > /root/.m2/settings.xml + +# 先复制pom.xml,利用Docker缓存下载依赖 +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# 复制源代码并构建 +COPY src ./src +RUN mvn package -DskipTests -B + +# 运行阶段 +FROM registry.cn-hangzhou.aliyuncs.com/acs/openjdk:8-jre + +WORKDIR /app + +# 设置时区为中国 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 从构建阶段复制jar包 +COPY --from=builder /app/target/*.jar app.jar + +# 暴露端口 +EXPOSE 8080 + +# JVM优化参数 +ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseG1GC -XX:MaxGCPauseMillis=100" + +# 启动命令 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + diff --git a/Dockerfile.simple b/Dockerfile.simple new file mode 100644 index 0000000..685ffdf --- /dev/null +++ b/Dockerfile.simple @@ -0,0 +1,21 @@ +# 简化版 - 需要先在本地执行 mvn package 构建好jar包 +# 使用方法: +# 1. mvn clean package -DskipTests +# 2. docker build -f Dockerfile.simple -t novel-reader:latest . + +FROM eclipse-temurin:8-jre + +WORKDIR /app + +# 设置时区 +ENV TZ=Asia/Shanghai + +# 复制本地构建好的jar包 +COPY target/*.jar app.jar + +EXPOSE 8080 + +ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseG1GC" + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afe2886 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + novel-reader: + build: . + # 如果已经构建好镜像,可以直接使用镜像名 + # image: novel-reader:latest + container_name: novel-reader + restart: unless-stopped + ports: + - "8080:8080" + environment: + # MongoDB连接配置 - 根据你的实际情况修改 + - SPRING_DATA_MONGODB_URI=mongodb://192.168.18.100:38403/novel + # JVM内存配置(根据NAS内存调整) + - JAVA_OPTS=-Xms128m -Xmx256m -XX:+UseG1GC + # 时区 + - TZ=Asia/Shanghai + # 健康检查 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/novels/tags/popular?limit=1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + # 日志配置 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # 资源限制(可选,根据NAS配置调整) + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8391c3b --- /dev/null +++ b/pom.xml @@ -0,0 +1,92 @@ + + 4.0.0 + com.novelreader + server + 0.0.1-SNAPSHOT + jar + novel-reader-server + + + 1.8 + 2.7.18 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + org.springframework.boot + spring-boot-starter-validation + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + com.novelreader.NovelReaderApplication + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + + diff --git a/src/main/java/com/novelreader/NovelReaderApplication.java b/src/main/java/com/novelreader/NovelReaderApplication.java new file mode 100644 index 0000000..0ec6e44 --- /dev/null +++ b/src/main/java/com/novelreader/NovelReaderApplication.java @@ -0,0 +1,12 @@ +package com.novelreader; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class NovelReaderApplication { + public static void main(String[] args) { + SpringApplication.run(NovelReaderApplication.class, args); + } +} + diff --git a/src/main/java/com/novelreader/config/MongoIndexConfig.java b/src/main/java/com/novelreader/config/MongoIndexConfig.java new file mode 100644 index 0000000..ed6d04d --- /dev/null +++ b/src/main/java/com/novelreader/config/MongoIndexConfig.java @@ -0,0 +1,80 @@ +package com.novelreader.config; + +import com.novelreader.model.ChapterDO; +import com.novelreader.model.NovelDO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.stereotype.Component; + +/** + * MongoDB索引配置 + * 在应用启动时确保必要的索引存在 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MongoIndexConfig { + + private final MongoTemplate mongoTemplate; + + @EventListener(ApplicationReadyEvent.class) + public void ensureIndexes() { + log.info("开始检查和创建MongoDB索引..."); + + try { + // Chapter集合索引 - 这是最关键的,因为有2800万条数据 + createChapterIndexes(); + + // Novel集合索引 + createNovelIndexes(); + + log.info("MongoDB索引检查完成"); + } catch (Exception e) { + log.error("创建索引失败", e); + } + } + + private void createChapterIndexes() { + IndexOperations indexOps = mongoTemplate.indexOps("chapterDO"); + + // 复合索引: novelId + index (最重要的索引,用于章节查询和排序) + indexOps.ensureIndex(new Index() + .on("novelId", Sort.Direction.ASC) + .on("index", Sort.Direction.ASC) + .named("idx_novelId_index") + .background()); // 后台创建,不阻塞 + + log.info("Chapter索引已确保: idx_novelId_index"); + } + + private void createNovelIndexes() { + IndexOperations indexOps = mongoTemplate.indexOps(NovelDO.class); + + // 名称索引 (用于搜索) + indexOps.ensureIndex(new Index() + .on("name", Sort.Direction.ASC) + .named("idx_name") + .background()); + + // 作者索引 (用于搜索) + indexOps.ensureIndex(new Index() + .on("author", Sort.Direction.ASC) + .named("idx_author") + .background()); + + // 标签索引 (用于标签筛选) + indexOps.ensureIndex(new Index() + .on("tags", Sort.Direction.ASC) + .named("idx_tags") + .background()); + + log.info("Novel索引已确保: idx_name, idx_author, idx_tags"); + } +} + diff --git a/src/main/java/com/novelreader/controller/BookmarkController.java b/src/main/java/com/novelreader/controller/BookmarkController.java new file mode 100644 index 0000000..d8ece55 --- /dev/null +++ b/src/main/java/com/novelreader/controller/BookmarkController.java @@ -0,0 +1,61 @@ +package com.novelreader.controller; + +import com.novelreader.dto.BookmarkRequest; +import com.novelreader.model.Bookmark; +import com.novelreader.service.BookmarkService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/bookmarks") +@RequiredArgsConstructor +@Tag(name = "书签", description = "书签增删查") +@CrossOrigin(origins = "*") +public class BookmarkController { + + private final BookmarkService bookmarkService; + + @GetMapping + @Operation(summary = "分页获取书签") + public ResponseEntity> list(@RequestParam(required = false) String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(bookmarkService.getBookmarks(userId, page, size)); + } + + @GetMapping("/novel/{novelId}") + @Operation(summary = "获取某部小说的书签") + public ResponseEntity> listByNovel(@PathVariable String novelId, + @RequestParam(required = false) String userId) { + return ResponseEntity.ok(bookmarkService.getBookmarksByNovel(userId, novelId)); + } + + @PostMapping + @Operation(summary = "创建书签") + public ResponseEntity create(@Valid @RequestBody BookmarkRequest request) { + Bookmark bookmark = bookmarkService.createBookmark( + request.getUserId(), + request.getNovelId(), + request.getChapterId(), + request.getPosition(), + request.getNote() + ); + return ResponseEntity.ok(bookmark); + } + + @DeleteMapping("/{bookmarkId}") + @Operation(summary = "删除书签") + public ResponseEntity delete(@PathVariable String bookmarkId, + @RequestParam(required = false) String userId) { + bookmarkService.deleteBookmark(userId, bookmarkId); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/novelreader/controller/ChapterController.java b/src/main/java/com/novelreader/controller/ChapterController.java new file mode 100644 index 0000000..b502575 --- /dev/null +++ b/src/main/java/com/novelreader/controller/ChapterController.java @@ -0,0 +1,60 @@ +package com.novelreader.controller; + +import com.novelreader.dto.ChapterContent; +import com.novelreader.service.ChapterService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/api/chapters") +@RequiredArgsConstructor +@Tag(name = "章节", description = "章节内容、上一章/下一章等接口") +@CrossOrigin(origins = "*") +public class ChapterController { + + private final ChapterService chapterService; + + @GetMapping("/{chapterId}") + @Operation(summary = "根据章节ID获取内容") + public ResponseEntity getById(@PathVariable String chapterId) { + Optional content = chapterService.getChapterContent(chapterId); + return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/novel/{novelId}") + @Operation(summary = "按小说ID和索引获取章节") + public ResponseEntity getByNovelAndIndex(@PathVariable String novelId, + @RequestParam Integer index) { + Optional content = chapterService.getChapterByNovelIdAndIndex(novelId, index); + return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/novel/{novelId}/next") + @Operation(summary = "获取下一章") + public ResponseEntity getNext(@PathVariable String novelId, + @RequestParam Integer currentIndex) { + Optional content = chapterService.getNextChapter(novelId, currentIndex); + return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/novel/{novelId}/prev") + @Operation(summary = "获取上一章") + public ResponseEntity getPrev(@PathVariable String novelId, + @RequestParam Integer currentIndex) { + Optional content = chapterService.getPrevChapter(novelId, currentIndex); + return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/novel/{novelId}/first") + @Operation(summary = "获取第一章") + public ResponseEntity getFirst(@PathVariable String novelId) { + Optional content = chapterService.getFirstChapter(novelId); + return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } +} + diff --git a/src/main/java/com/novelreader/controller/HistoryController.java b/src/main/java/com/novelreader/controller/HistoryController.java new file mode 100644 index 0000000..8366b79 --- /dev/null +++ b/src/main/java/com/novelreader/controller/HistoryController.java @@ -0,0 +1,61 @@ +package com.novelreader.controller; + +import com.novelreader.dto.HistoryRequest; +import com.novelreader.model.History; +import com.novelreader.service.HistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/api/history") +@RequiredArgsConstructor +@Tag(name = "阅读历史", description = "阅读进度查询与更新") +@CrossOrigin(origins = "*") +public class HistoryController { + + private final HistoryService historyService; + + @GetMapping + @Operation(summary = "分页获取阅读历史") + public ResponseEntity> list(@RequestParam(required = false) String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(historyService.getHistory(userId, page, size)); + } + + @GetMapping("/novel/{novelId}") + @Operation(summary = "获取某本书的历史进度") + public ResponseEntity getByNovel(@PathVariable String novelId, + @RequestParam(required = false) String userId) { + Optional history = historyService.getHistoryByNovel(userId, novelId); + return history.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + @Operation(summary = "更新阅读进度") + public ResponseEntity save(@Valid @RequestBody HistoryRequest request) { + History history = historyService.updateHistory( + request.getUserId(), + request.getNovelId(), + request.getChapterId(), + request.getPosition() + ); + return ResponseEntity.ok(history); + } + + @DeleteMapping("/novel/{novelId}") + @Operation(summary = "删除某本书的历史记录") + public ResponseEntity delete(@PathVariable String novelId, + @RequestParam(required = false) String userId) { + historyService.deleteHistory(userId, novelId); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/novelreader/controller/NovelController.java b/src/main/java/com/novelreader/controller/NovelController.java new file mode 100644 index 0000000..7583c9b --- /dev/null +++ b/src/main/java/com/novelreader/controller/NovelController.java @@ -0,0 +1,65 @@ +package com.novelreader.controller; + +import com.novelreader.dto.ChapterHead; +import com.novelreader.dto.NovelBrief; +import com.novelreader.dto.NovelDetail; +import com.novelreader.service.NovelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("/api/novels") +@RequiredArgsConstructor +@Tag(name = "小说管理", description = "小说搜索、详情、章节列表等接口") +@CrossOrigin(origins = "*") +public class NovelController { + + private final NovelService novelService; + + @GetMapping + @Operation(summary = "搜索小说", description = "根据关键词或标签搜索小说") + public ResponseEntity> searchNovels( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String tag, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(novelService.searchNovels(keyword, tag, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "获取小说详情") + public ResponseEntity getNovelDetail(@PathVariable String id) { + Optional detail = novelService.getNovelDetail(id); + return detail.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/{id}/chapters") + @Operation(summary = "获取章节列表") + public ResponseEntity> getChapters( + @PathVariable String id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + return ResponseEntity.ok(novelService.getChapters(id, page, size)); + } + + @GetMapping("/tags/popular") + @Operation(summary = "获取热门标签") + public ResponseEntity> getPopularTags(@RequestParam(defaultValue = "20") int limit) { + return ResponseEntity.ok(novelService.getPopularTags(limit)); + } + + @GetMapping("/random") + @Operation(summary = "随机获取小说", description = "从数据库中随机获取指定数量的小说") + public ResponseEntity> getRandomNovels(@RequestParam(defaultValue = "10") int size) { + return ResponseEntity.ok(novelService.getRandomNovels(size)); + } +} + diff --git a/src/main/java/com/novelreader/controller/PageController.java b/src/main/java/com/novelreader/controller/PageController.java new file mode 100644 index 0000000..df9a85e --- /dev/null +++ b/src/main/java/com/novelreader/controller/PageController.java @@ -0,0 +1,44 @@ +package com.novelreader.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class PageController { + + @GetMapping("/") + public String index() { + return "index"; + } + + @GetMapping("/shelf") + public String shelf() { + return "index"; + } + + @GetMapping("/search") + public String search() { + return "index"; + } + + @GetMapping("/novel/{id}") + public String novelDetail() { + return "index"; + } + + @GetMapping("/read/{novelId}/{chapterIndex}") + public String read() { + return "index"; + } + + @GetMapping("/bookmarks") + public String bookmarks() { + return "index"; + } + + @GetMapping("/history") + public String history() { + return "index"; + } +} + diff --git a/src/main/java/com/novelreader/controller/ShelfController.java b/src/main/java/com/novelreader/controller/ShelfController.java new file mode 100644 index 0000000..9bb43da --- /dev/null +++ b/src/main/java/com/novelreader/controller/ShelfController.java @@ -0,0 +1,53 @@ +package com.novelreader.controller; + +import com.novelreader.dto.ShelfRequest; +import com.novelreader.model.ShelfItem; +import com.novelreader.service.ShelfService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/shelf") +@RequiredArgsConstructor +@Tag(name = "书架", description = "书架增删查") +@CrossOrigin(origins = "*") +public class ShelfController { + + private final ShelfService shelfService; + + @GetMapping + @Operation(summary = "分页获取书架") + public ResponseEntity> list(@RequestParam(required = false) String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(shelfService.getShelf(userId, page, size)); + } + + @PostMapping + @Operation(summary = "加入书架") + public ResponseEntity add(@Valid @RequestBody ShelfRequest request) { + ShelfItem item = shelfService.addToShelf(request.getUserId(), request.getNovelId(), request.getTags()); + return ResponseEntity.ok(item); + } + + @DeleteMapping("/{novelId}") + @Operation(summary = "从书架移除") + public ResponseEntity delete(@PathVariable String novelId, + @RequestParam(required = false) String userId) { + shelfService.removeFromShelf(userId, novelId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{novelId}/exists") + @Operation(summary = "检测是否在书架") + public ResponseEntity exists(@PathVariable String novelId, + @RequestParam(required = false) String userId) { + return ResponseEntity.ok(shelfService.isInShelf(userId, novelId)); + } +} + diff --git a/src/main/java/com/novelreader/dto/BookmarkRequest.java b/src/main/java/com/novelreader/dto/BookmarkRequest.java new file mode 100644 index 0000000..b6b9ee9 --- /dev/null +++ b/src/main/java/com/novelreader/dto/BookmarkRequest.java @@ -0,0 +1,17 @@ +package com.novelreader.dto; + +import javax.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class BookmarkRequest { + @NotBlank + private String novelId; + @NotBlank + private String chapterId; + private Integer position; + private String note; + // 简单自用场景,用户ID可选;不传时服务层使用默认用户 + private String userId; +} + diff --git a/src/main/java/com/novelreader/dto/ChapterContent.java b/src/main/java/com/novelreader/dto/ChapterContent.java new file mode 100644 index 0000000..e9825b4 --- /dev/null +++ b/src/main/java/com/novelreader/dto/ChapterContent.java @@ -0,0 +1,19 @@ +package com.novelreader.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChapterContent { + private String id; + private String novelId; + private Integer index; + private String title; + private String content; +} + diff --git a/src/main/java/com/novelreader/dto/ChapterHead.java b/src/main/java/com/novelreader/dto/ChapterHead.java new file mode 100644 index 0000000..414361f --- /dev/null +++ b/src/main/java/com/novelreader/dto/ChapterHead.java @@ -0,0 +1,18 @@ +package com.novelreader.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChapterHead { + private String id; + private String title; + private Integer index; + private String novelId; +} + diff --git a/src/main/java/com/novelreader/dto/HistoryRequest.java b/src/main/java/com/novelreader/dto/HistoryRequest.java new file mode 100644 index 0000000..349808c --- /dev/null +++ b/src/main/java/com/novelreader/dto/HistoryRequest.java @@ -0,0 +1,15 @@ +package com.novelreader.dto; + +import javax.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class HistoryRequest { + @NotBlank + private String novelId; + @NotBlank + private String chapterId; + private Integer position; + private String userId; +} + diff --git a/src/main/java/com/novelreader/dto/NovelBrief.java b/src/main/java/com/novelreader/dto/NovelBrief.java new file mode 100644 index 0000000..7cc80f7 --- /dev/null +++ b/src/main/java/com/novelreader/dto/NovelBrief.java @@ -0,0 +1,23 @@ +package com.novelreader.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NovelBrief { + private String id; + private String name; + private String author; + private String cover; + private String synopsis; + private Integer status; + private List tags; +} + diff --git a/src/main/java/com/novelreader/dto/NovelDetail.java b/src/main/java/com/novelreader/dto/NovelDetail.java new file mode 100644 index 0000000..bd83eb4 --- /dev/null +++ b/src/main/java/com/novelreader/dto/NovelDetail.java @@ -0,0 +1,26 @@ +package com.novelreader.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NovelDetail { + private String id; + private String name; + private String author; + private String cover; + private String synopsis; + private String sourceSite; + private Integer status; + private List tags; + private Long chapterCount; + private Integer lastChapterIndex; +} + diff --git a/src/main/java/com/novelreader/dto/ShelfRequest.java b/src/main/java/com/novelreader/dto/ShelfRequest.java new file mode 100644 index 0000000..a26c23f --- /dev/null +++ b/src/main/java/com/novelreader/dto/ShelfRequest.java @@ -0,0 +1,15 @@ +package com.novelreader.dto; + +import javax.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +@Data +public class ShelfRequest { + @NotBlank + private String novelId; + private List tags; + private String userId; +} + diff --git a/src/main/java/com/novelreader/model/Bookmark.java b/src/main/java/com/novelreader/model/Bookmark.java new file mode 100644 index 0000000..aff3c0d --- /dev/null +++ b/src/main/java/com/novelreader/model/Bookmark.java @@ -0,0 +1,21 @@ +package com.novelreader.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +@Data +@Document(collection = "bookmark") +public class Bookmark { + @Id + private String id; + private String userId; + private String novelId; + private String chapterId; + private Integer position; // offset in chapter content + private String note; + private Instant createdAt; +} + diff --git a/src/main/java/com/novelreader/model/ChapterDO.java b/src/main/java/com/novelreader/model/ChapterDO.java new file mode 100644 index 0000000..b62ea5f --- /dev/null +++ b/src/main/java/com/novelreader/model/ChapterDO.java @@ -0,0 +1,20 @@ +package com.novelreader.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +@Data +@Document(collection = "chapterDO") +public class ChapterDO { + @Id + private String id; + private String novelId; + private Integer index; + private String title; + private String content; + private Instant createdAt; +} + diff --git a/src/main/java/com/novelreader/model/History.java b/src/main/java/com/novelreader/model/History.java new file mode 100644 index 0000000..85266b1 --- /dev/null +++ b/src/main/java/com/novelreader/model/History.java @@ -0,0 +1,20 @@ +package com.novelreader.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +@Data +@Document(collection = "history") +public class History { + @Id + private String id; + private String userId; + private String novelId; + private String chapterId; + private Integer position; + private Instant lastReadAt; +} + diff --git a/src/main/java/com/novelreader/model/NovelDO.java b/src/main/java/com/novelreader/model/NovelDO.java new file mode 100644 index 0000000..ea6bf8f --- /dev/null +++ b/src/main/java/com/novelreader/model/NovelDO.java @@ -0,0 +1,30 @@ +package com.novelreader.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.Instant; +import java.util.List; + +@Data +@Document(collection = "novelDO") +public class NovelDO { + @Id + private String id; + + private String name; + private String author; + private String cover; + private String sourceSite; + private Integer status; + private String synopsis; + private List tags; + + @Field("createdAt") + private Instant createdAt; + @Field("updatedAt") + private Instant updatedAt; +} + diff --git a/src/main/java/com/novelreader/model/ShelfItem.java b/src/main/java/com/novelreader/model/ShelfItem.java new file mode 100644 index 0000000..162a726 --- /dev/null +++ b/src/main/java/com/novelreader/model/ShelfItem.java @@ -0,0 +1,20 @@ +package com.novelreader.model; + +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.List; + +@Data +@Document(collection = "shelf") +public class ShelfItem { + @Id + private String id; + private String userId; + private String novelId; + private Instant addedAt; + private List tags; +} + diff --git a/src/main/java/com/novelreader/repository/BookmarkRepository.java b/src/main/java/com/novelreader/repository/BookmarkRepository.java new file mode 100644 index 0000000..90ccb7c --- /dev/null +++ b/src/main/java/com/novelreader/repository/BookmarkRepository.java @@ -0,0 +1,23 @@ +package com.novelreader.repository; + +import com.novelreader.model.Bookmark; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BookmarkRepository extends MongoRepository { + + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + + List findByUserIdAndNovelId(String userId, String novelId); + + Optional findByUserIdAndNovelIdAndChapterId(String userId, String novelId, String chapterId); + + void deleteByUserIdAndId(String userId, String id); +} + diff --git a/src/main/java/com/novelreader/repository/ChapterRepository.java b/src/main/java/com/novelreader/repository/ChapterRepository.java new file mode 100644 index 0000000..aa87293 --- /dev/null +++ b/src/main/java/com/novelreader/repository/ChapterRepository.java @@ -0,0 +1,24 @@ +package com.novelreader.repository; + +import com.novelreader.model.ChapterDO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ChapterRepository extends MongoRepository { + + Page findByNovelIdOrderByIndexAsc(String novelId, Pageable pageable); + + Optional findByNovelIdAndIndex(String novelId, Integer index); + + Optional findFirstByNovelIdOrderByIndexAsc(String novelId); + + Optional findFirstByNovelIdOrderByIndexDesc(String novelId); + + long countByNovelId(String novelId); +} + diff --git a/src/main/java/com/novelreader/repository/HistoryRepository.java b/src/main/java/com/novelreader/repository/HistoryRepository.java new file mode 100644 index 0000000..df019e5 --- /dev/null +++ b/src/main/java/com/novelreader/repository/HistoryRepository.java @@ -0,0 +1,20 @@ +package com.novelreader.repository; + +import com.novelreader.model.History; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface HistoryRepository extends MongoRepository { + + Page findByUserIdOrderByLastReadAtDesc(String userId, Pageable pageable); + + Optional findByUserIdAndNovelId(String userId, String novelId); + + void deleteByUserIdAndNovelId(String userId, String novelId); +} + diff --git a/src/main/java/com/novelreader/repository/NovelRepository.java b/src/main/java/com/novelreader/repository/NovelRepository.java new file mode 100644 index 0000000..6248d1f --- /dev/null +++ b/src/main/java/com/novelreader/repository/NovelRepository.java @@ -0,0 +1,26 @@ +package com.novelreader.repository; + +import com.novelreader.model.NovelDO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NovelRepository extends MongoRepository { + + @Query("{ $or: [ { 'name': { $regex: ?0, $options: 'i' } }, { 'author': { $regex: ?0, $options: 'i' } } ] }") + Page findByNameOrAuthorContainingIgnoreCase(String keyword, Pageable pageable); + + @Query("{ 'tags': { $in: ?0 } }") + Page findByTagsIn(List tags, Pageable pageable); + + @Query("{ $and: [ { $or: [ { 'name': { $regex: ?0, $options: 'i' } }, { 'author': { $regex: ?0, $options: 'i' } } ] }, { 'tags': { $in: ?1 } } ] }") + Page findByNameOrAuthorAndTags(String keyword, List tags, Pageable pageable); + + Page findAll(Pageable pageable); +} + diff --git a/src/main/java/com/novelreader/repository/ShelfRepository.java b/src/main/java/com/novelreader/repository/ShelfRepository.java new file mode 100644 index 0000000..34a7163 --- /dev/null +++ b/src/main/java/com/novelreader/repository/ShelfRepository.java @@ -0,0 +1,22 @@ +package com.novelreader.repository; + +import com.novelreader.model.ShelfItem; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ShelfRepository extends MongoRepository { + + Page findByUserIdOrderByAddedAtDesc(String userId, Pageable pageable); + + Optional findByUserIdAndNovelId(String userId, String novelId); + + boolean existsByUserIdAndNovelId(String userId, String novelId); + + void deleteByUserIdAndNovelId(String userId, String novelId); +} + diff --git a/src/main/java/com/novelreader/service/BookmarkService.java b/src/main/java/com/novelreader/service/BookmarkService.java new file mode 100644 index 0000000..096da57 --- /dev/null +++ b/src/main/java/com/novelreader/service/BookmarkService.java @@ -0,0 +1,57 @@ +package com.novelreader.service; + +import com.novelreader.model.Bookmark; +import com.novelreader.repository.BookmarkRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private static final String DEFAULT_USER_ID = "default_user"; + + public Page getBookmarks(String userId, int page, int size) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + Pageable pageable = PageRequest.of(page, size); + return bookmarkRepository.findByUserIdOrderByCreatedAtDesc(uid, pageable); + } + + public List getBookmarksByNovel(String userId, String novelId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + return bookmarkRepository.findByUserIdAndNovelId(uid, novelId); + } + + public Bookmark createBookmark(String userId, String novelId, String chapterId, Integer position, String note) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + + Bookmark bookmark = new Bookmark(); + bookmark.setUserId(uid); + bookmark.setNovelId(novelId); + bookmark.setChapterId(chapterId); + bookmark.setPosition(position != null ? position : 0); + bookmark.setNote(note); + bookmark.setCreatedAt(Instant.now()); + + return bookmarkRepository.save(bookmark); + } + + public void deleteBookmark(String userId, String bookmarkId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + bookmarkRepository.deleteByUserIdAndId(uid, bookmarkId); + } + + public boolean bookmarkExists(String userId, String novelId, String chapterId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + return bookmarkRepository.findByUserIdAndNovelIdAndChapterId(uid, novelId, chapterId).isPresent(); + } +} + diff --git a/src/main/java/com/novelreader/service/ChapterService.java b/src/main/java/com/novelreader/service/ChapterService.java new file mode 100644 index 0000000..c707941 --- /dev/null +++ b/src/main/java/com/novelreader/service/ChapterService.java @@ -0,0 +1,50 @@ +package com.novelreader.service; + +import com.novelreader.dto.ChapterContent; +import com.novelreader.model.ChapterDO; +import com.novelreader.repository.ChapterRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ChapterService { + + private final ChapterRepository chapterRepository; + + public Optional getChapterContent(String chapterId) { + return chapterRepository.findById(chapterId).map(this::toContent); + } + + public Optional getChapterByNovelIdAndIndex(String novelId, Integer index) { + return chapterRepository.findByNovelIdAndIndex(novelId, index).map(this::toContent); + } + + public Optional getNextChapter(String novelId, Integer currentIndex) { + return chapterRepository.findByNovelIdAndIndex(novelId, currentIndex + 1) + .map(this::toContent); + } + + public Optional getPrevChapter(String novelId, Integer currentIndex) { + return chapterRepository.findByNovelIdAndIndex(novelId, currentIndex - 1) + .map(this::toContent); + } + + public Optional getFirstChapter(String novelId) { + return chapterRepository.findFirstByNovelIdOrderByIndexAsc(novelId) + .map(this::toContent); + } + + private ChapterContent toContent(ChapterDO chapter) { + ChapterContent content = new ChapterContent(); + content.setId(chapter.getId()); + content.setNovelId(chapter.getNovelId()); + content.setIndex(chapter.getIndex()); + content.setTitle(chapter.getTitle()); + content.setContent(chapter.getContent()); + return content; + } +} + diff --git a/src/main/java/com/novelreader/service/HistoryService.java b/src/main/java/com/novelreader/service/HistoryService.java new file mode 100644 index 0000000..6aabc24 --- /dev/null +++ b/src/main/java/com/novelreader/service/HistoryService.java @@ -0,0 +1,52 @@ +package com.novelreader.service; + +import com.novelreader.model.History; +import com.novelreader.repository.HistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class HistoryService { + + private final HistoryRepository historyRepository; + private static final String DEFAULT_USER_ID = "default_user"; + + public Page getHistory(String userId, int page, int size) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + Pageable pageable = PageRequest.of(page, size); + return historyRepository.findByUserIdOrderByLastReadAtDesc(uid, pageable); + } + + public Optional getHistoryByNovel(String userId, String novelId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + return historyRepository.findByUserIdAndNovelId(uid, novelId); + } + + public History updateHistory(String userId, String novelId, String chapterId, Integer position) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + + Optional existing = historyRepository.findByUserIdAndNovelId(uid, novelId); + + History history = existing.orElse(new History()); + history.setUserId(uid); + history.setNovelId(novelId); + history.setChapterId(chapterId); + history.setPosition(position != null ? position : 0); + history.setLastReadAt(Instant.now()); + + return historyRepository.save(history); + } + + public void deleteHistory(String userId, String novelId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + historyRepository.deleteByUserIdAndNovelId(uid, novelId); + } +} + diff --git a/src/main/java/com/novelreader/service/NovelService.java b/src/main/java/com/novelreader/service/NovelService.java new file mode 100644 index 0000000..520a30f --- /dev/null +++ b/src/main/java/com/novelreader/service/NovelService.java @@ -0,0 +1,150 @@ +package com.novelreader.service; + +import com.novelreader.dto.ChapterHead; +import com.novelreader.dto.NovelBrief; +import com.novelreader.dto.NovelDetail; +import com.novelreader.model.NovelDO; +import com.novelreader.repository.ChapterRepository; +import com.novelreader.repository.NovelRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NovelService { + + private final NovelRepository novelRepository; + private final ChapterRepository chapterRepository; + private final MongoTemplate mongoTemplate; + + public Page searchNovels(String keyword, String tag, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page novels; + + List tags = tag != null && !tag.isEmpty() ? Collections.singletonList(tag) : null; + + if (keyword != null && !keyword.isEmpty() && tags != null) { + novels = novelRepository.findByNameOrAuthorAndTags(keyword, tags, pageable); + } else if (keyword != null && !keyword.isEmpty()) { + novels = novelRepository.findByNameOrAuthorContainingIgnoreCase(keyword, pageable); + } else if (tags != null) { + novels = novelRepository.findByTagsIn(tags, pageable); + } else { + novels = novelRepository.findAll(pageable); + } + + return novels.map(this::toBrief); + } + + public Optional getNovelDetail(String id) { + return novelRepository.findById(id).map(novel -> { + NovelDetail detail = toDetail(novel); + long chapterCount = chapterRepository.countByNovelId(id); + detail.setChapterCount(chapterCount); + chapterRepository.findFirstByNovelIdOrderByIndexDesc(id) + .ifPresent(c -> detail.setLastChapterIndex(c.getIndex())); + return detail; + }); + } + + public Page getChapters(String novelId, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + return chapterRepository.findByNovelIdOrderByIndexAsc(novelId, pageable) + .map(chapter -> { + ChapterHead head = new ChapterHead(); + head.setId(chapter.getId()); + head.setTitle(chapter.getTitle()); + head.setIndex(chapter.getIndex()); + head.setNovelId(chapter.getNovelId()); + return head; + }); + } + + /** + * 获取热门标签 - 使用MongoDB聚合优化 + */ + public List getPopularTags(int limit) { + // 使用聚合管道在数据库端统计,避免加载全部数据到内存 + Aggregation aggregation = Aggregation.newAggregation( + // 只投影tags字段,减少数据传输 + Aggregation.project("tags"), + // 展开tags数组 + Aggregation.unwind("tags"), + // 按标签分组并计数 + Aggregation.group("tags").count().as("count"), + // 按数量降序排序 + Aggregation.sort(org.springframework.data.domain.Sort.Direction.DESC, "count"), + // 限制返回数量 + Aggregation.limit(limit) + ); + + AggregationResults results = mongoTemplate.aggregate( + aggregation, "novelDO", TagCount.class + ); + + return results.getMappedResults().stream() + .map(TagCount::getId) + .collect(Collectors.toList()); + } + + // 标签统计结果类 + @lombok.Data + private static class TagCount { + private String id; // 标签名 + private long count; // 数量 + } + + /** + * 随机获取小说 + * @param size 数量 + * @return 随机小说列表 + */ + public List getRandomNovels(int size) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.sample(size) + ); + AggregationResults results = mongoTemplate.aggregate( + aggregation, "novelDO", NovelDO.class + ); + return results.getMappedResults().stream() + .map(this::toBrief) + .collect(Collectors.toList()); + } + + private NovelBrief toBrief(NovelDO novel) { + NovelBrief brief = new NovelBrief(); + brief.setId(novel.getId()); + brief.setName(novel.getName()); + brief.setAuthor(novel.getAuthor()); + brief.setCover(novel.getCover()); + brief.setSynopsis(novel.getSynopsis()); + brief.setTags(novel.getTags()); + brief.setStatus(novel.getStatus()); + return brief; + } + + private NovelDetail toDetail(NovelDO novel) { + NovelDetail detail = new NovelDetail(); + detail.setId(novel.getId()); + detail.setName(novel.getName()); + detail.setAuthor(novel.getAuthor()); + detail.setCover(novel.getCover()); + detail.setSourceSite(novel.getSourceSite()); + detail.setStatus(novel.getStatus()); + detail.setSynopsis(novel.getSynopsis()); + detail.setTags(novel.getTags()); + return detail; + } +} + diff --git a/src/main/java/com/novelreader/service/ShelfService.java b/src/main/java/com/novelreader/service/ShelfService.java new file mode 100644 index 0000000..88426a1 --- /dev/null +++ b/src/main/java/com/novelreader/service/ShelfService.java @@ -0,0 +1,59 @@ +package com.novelreader.service; + +import com.novelreader.model.ShelfItem; +import com.novelreader.repository.ShelfRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ShelfService { + + private final ShelfRepository shelfRepository; + private static final String DEFAULT_USER_ID = "default_user"; + + public Page getShelf(String userId, int page, int size) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + Pageable pageable = PageRequest.of(page, size); + return shelfRepository.findByUserIdOrderByAddedAtDesc(uid, pageable); + } + + public ShelfItem addToShelf(String userId, String novelId, List tags) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + + Optional existing = shelfRepository.findByUserIdAndNovelId(uid, novelId); + if (existing.isPresent()) { + ShelfItem item = existing.get(); + if (tags != null && !tags.isEmpty()) { + item.setTags(tags); + } + return shelfRepository.save(item); + } + + ShelfItem item = new ShelfItem(); + item.setUserId(uid); + item.setNovelId(novelId); + item.setTags(tags); + item.setAddedAt(Instant.now()); + + return shelfRepository.save(item); + } + + public void removeFromShelf(String userId, String novelId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + shelfRepository.deleteByUserIdAndNovelId(uid, novelId); + } + + public boolean isInShelf(String userId, String novelId) { + String uid = userId != null ? userId : DEFAULT_USER_ID; + return shelfRepository.existsByUserIdAndNovelId(uid, novelId); + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..71abfe2 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,14 @@ +server: + port: 8080 + +spring: + data: + mongodb: + uri: mongodb://192.168.18.100:38403/aurora + +logging: + level: + root: INFO + org.springframework.web: INFO + com.novelreader: DEBUG + diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css new file mode 100644 index 0000000..7f3e41b --- /dev/null +++ b/src/main/resources/static/css/app.css @@ -0,0 +1,9 @@ +/* ============================================ + 主样式入口 - 导入所有CSS模块 + ============================================ */ + +@import url('base.css'); +@import url('components.css'); +@import url('pages.css'); +@import url('reader.css'); + diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css new file mode 100644 index 0000000..a19a9e9 --- /dev/null +++ b/src/main/resources/static/css/base.css @@ -0,0 +1,337 @@ +/* ============================================ + 基础样式 - CSS变量、重置、通用样式 + ============================================ */ + +:root { + /* 主题色 */ + --primary: #d4a574; + --primary-dark: #b8956a; + --primary-light: #e8c9a8; + + /* 深色主题(默认) */ + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-tertiary: #0f0f23; + --bg-card: #232341; + --bg-hover: #2d2d4a; + + --text-primary: #eaeaea; + --text-secondary: #a0a0b0; + --text-muted: #6a6a7a; + + --border-color: #3a3a5a; + --shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.4); + + /* 阅读器主题 */ + --reader-bg: #1a1a2e; + --reader-text: #d4d4d4; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* 圆角 */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-full: 9999px; + + /* 字体 */ + --font-display: 'Ma Shan Zheng', 'STKaiti', 'KaiTi', cursive; + --font-body: 'Noto Serif SC', 'STSong', 'SimSun', serif; + --font-size-xs: 12px; + --font-size-sm: 14px; + --font-size-md: 16px; + --font-size-lg: 18px; + --font-size-xl: 22px; + --font-size-2xl: 28px; + + /* 过渡 */ + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; + --transition-slow: 0.4s ease; + + /* 安全区域 */ + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + + /* 布局 */ + --header-height: 56px; + --nav-height: 60px; +} + +/* 浅色主题 */ +[data-theme="light"] { + --bg-primary: #f8f5f0; + --bg-secondary: #ffffff; + --bg-tertiary: #ede8e0; + --bg-card: #ffffff; + --bg-hover: #f0ebe3; + + --text-primary: #2d2a26; + --text-secondary: #5a5650; + --text-muted: #8a867e; + + --border-color: #d8d4cc; + --shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.12); + + --reader-bg: #f8f5f0; + --reader-text: #2d2a26; +} + +/* 护眼主题 */ +[data-theme="sepia"] { + --bg-primary: #f4ecd8; + --bg-secondary: #faf6eb; + --bg-tertiary: #e8dfc8; + --bg-card: #faf6eb; + --bg-hover: #efe6d2; + + --text-primary: #5b4636; + --text-secondary: #7a6652; + --text-muted: #9a8a76; + + --border-color: #d4c8b0; + --shadow: 0 4px 20px rgba(91, 70, 54, 0.1); + --shadow-lg: 0 8px 40px rgba(91, 70, 54, 0.15); + + --reader-bg: #f4ecd8; + --reader-text: #5b4636; +} + +/* 纯黑主题 */ +[data-theme="amoled"] { + --bg-primary: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #000000; + --bg-card: #121212; + --bg-hover: #1a1a1a; + + --text-primary: #e0e0e0; + --text-secondary: #909090; + --text-muted: #606060; + + --border-color: #2a2a2a; + --shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.6); + + --reader-bg: #000000; + --reader-text: #c0c0c0; +} + +/* 重置样式 */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-body); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + min-height: 100dvh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + outline: none; +} + +input, textarea { + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + outline: none; +} + +img { + max-width: 100%; + height: auto; + display: block; +} + +ul, ol { + list-style: none; +} + +/* SVG默认样式 */ +svg { + display: block; + flex-shrink: 0; +} + +svg:not([width]):not([class]) { + width: 24px; + height: 24px; +} + +svg path { + fill: inherit; +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--text-muted); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* 选中文本样式 */ +::selection { + background: var(--primary); + color: var(--bg-primary); +} + +/* 通用工具类 */ +.hidden { + display: none !important; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.text-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* 动画 */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes inkDrop { + 0% { + transform: scale(0); + opacity: 1; + } + 50% { + transform: scale(1); + opacity: 0.8; + } + 100% { + transform: scale(1.2); + opacity: 0; + } +} + +.animate-fade-in { + animation: fadeIn var(--transition-normal) ease forwards; +} + +.animate-fade-in-up { + animation: fadeInUp var(--transition-normal) ease forwards; +} + +.animate-slide-in-right { + animation: slideInRight var(--transition-normal) ease forwards; +} + diff --git a/src/main/resources/static/css/components.css b/src/main/resources/static/css/components.css new file mode 100644 index 0000000..bc8fbe4 --- /dev/null +++ b/src/main/resources/static/css/components.css @@ -0,0 +1,615 @@ +/* ============================================ + 组件样式 - 按钮、卡片、模态框、加载器等 + ============================================ */ + +/* 加载器 */ +#app-loader { + position: fixed; + inset: 0; + background: var(--bg-primary); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + transition: opacity var(--transition-slow), visibility var(--transition-slow); +} + +#app-loader.fade-out { + opacity: 0; + visibility: hidden; +} + +.loader-content { + text-align: center; +} + +.ink-drop { + width: 60px; + height: 60px; + margin: 0 auto 20px; + background: radial-gradient(circle, var(--primary) 0%, transparent 70%); + border-radius: 50%; + animation: inkDrop 1.5s ease-in-out infinite; +} + +.loader-text { + font-family: var(--font-display); + font-size: var(--font-size-2xl); + color: var(--primary); + letter-spacing: 4px; +} + +/* 头部导航 */ +#app-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: calc(var(--header-height) + var(--safe-area-top)); + padding-top: var(--safe-area-top); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding-left: max(var(--spacing-md), var(--safe-area-left)); + padding-right: max(var(--spacing-md), var(--safe-area-right)); + z-index: 100; + transition: transform var(--transition-normal), background var(--transition-normal); +} + +#app-header.header-hidden { + transform: translateY(-100%); +} + +.header-left, +.header-right { + display: flex; + align-items: center; + gap: var(--spacing-xs); + min-width: 80px; +} + +.header-right { + justify-content: flex-end; +} + +.header-title { + font-family: var(--font-display); + font-size: var(--font-size-xl); + color: var(--primary); + letter-spacing: 2px; + text-align: center; + flex: 1; +} + +.header-btn { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + transition: background var(--transition-fast); +} + +.header-btn:active { + background: var(--bg-hover); +} + +.header-btn svg { + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + max-width: 24px; + max-height: 24px; + fill: var(--text-primary); +} + +/* 底部导航 */ +#app-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(var(--nav-height) + var(--safe-area-bottom)); + padding-bottom: var(--safe-area-bottom); + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + display: flex; + align-items: stretch; + justify-content: space-around; + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); + z-index: 100; + transition: transform var(--transition-normal); +} + +#app-nav.nav-hidden { + transform: translateY(100%); +} + +.nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding-top: var(--spacing-sm); + color: var(--text-muted); + transition: color var(--transition-fast); +} + +.nav-item svg { + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + max-width: 24px; + max-height: 24px; + fill: currentColor; + transition: transform var(--transition-fast); +} + +.nav-item span { + font-size: var(--font-size-xs); +} + +.nav-item.active { + color: var(--primary); +} + +.nav-item.active svg { + transform: scale(1.1); +} + +/* 主内容区 */ +#app-content { + min-height: 100vh; + min-height: 100dvh; + padding-top: calc(var(--header-height) + var(--safe-area-top)); + padding-bottom: calc(var(--nav-height) + var(--safe-area-bottom)); + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); + transition: padding var(--transition-normal); +} + +#app-content.reader-mode { + padding: 0; +} + +#app-content.detail-mode { + padding-bottom: 0; /* 详情页有自己的底部操作栏 */ +} + +/* Toast 提示 */ +#toast { + position: fixed; + bottom: calc(var(--nav-height) + var(--safe-area-bottom) + 20px); + left: 50%; + transform: translateX(-50%) translateY(20px); + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: var(--spacing-sm) var(--spacing-lg); + border-radius: var(--radius-full); + box-shadow: var(--shadow-lg); + font-size: var(--font-size-sm); + opacity: 0; + transition: opacity var(--transition-fast), transform var(--transition-fast); + z-index: 1000; + max-width: 80%; + text-align: center; + white-space: nowrap; +} + +/* 阅读模式下Toast位置调整 */ +.reader-mode ~ #toast, +body:has(.reader-mode) #toast { + bottom: calc(100px + var(--safe-area-bottom)); +} + +#toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* 模态框 */ +.modal { + position: fixed; + inset: 0; + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + width: 100%; + max-width: 340px; + background: var(--bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + animation: fadeInUp var(--transition-normal) ease; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); +} + +.modal-header span { + font-size: var(--font-size-lg); + font-weight: 600; +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); +} + +.modal-close svg { + width: 20px; + height: 20px; + fill: var(--text-secondary); +} + +.modal-body { + padding: var(--spacing-md); +} + +.modal-body textarea { + width: 100%; + min-height: 100px; + padding: var(--spacing-md); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + resize: none; +} + +.modal-body textarea::placeholder { + color: var(--text-muted); +} + +.modal-footer { + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-md); + border-top: 1px solid var(--border-color); +} + +.modal-footer button { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-weight: 500; + transition: background var(--transition-fast); +} + +.btn-cancel { + background: var(--bg-hover); +} + +.btn-confirm { + background: var(--primary); + color: var(--bg-primary); +} + +/* 小说卡片 */ +.novel-card { + display: flex; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-md); + transition: transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.novel-card:active { + transform: scale(0.98); +} + +.novel-cover { + width: 80px; + height: 110px; + border-radius: var(--radius-sm); + overflow: hidden; + flex-shrink: 0; + background: var(--bg-hover); +} + +.novel-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.novel-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.novel-title { + font-size: var(--font-size-md); + font-weight: 600; + color: var(--text-primary); +} + +.novel-author { + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.novel-synopsis { + font-size: var(--font-size-sm); + color: var(--text-muted); + line-height: 1.5; +} + +.novel-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-top: auto; +} + +.tag { + display: inline-block; + padding: 2px 8px; + background: var(--bg-hover); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + color: var(--primary); +} + +/* 网格布局卡片 */ +.novel-grid-card { + display: flex; + flex-direction: column; + background: var(--bg-card); + border-radius: var(--radius-md); + overflow: hidden; + transition: transform var(--transition-fast); +} + +.novel-grid-card:active { + transform: scale(0.96); +} + +.novel-grid-card .novel-cover { + width: 100%; + height: 0; + padding-bottom: 140%; + position: relative; +} + +.novel-grid-card .novel-cover img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.novel-grid-card .novel-info { + padding: var(--spacing-sm); +} + +.novel-grid-card .novel-title { + font-size: var(--font-size-sm); +} + +.novel-grid-card .novel-author { + font-size: var(--font-size-xs); +} + +/* 章节列表项 */ +.chapter-item { + display: flex; + align-items: center; + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); + transition: background var(--transition-fast); +} + +.chapter-item:active { + background: var(--bg-hover); +} + +.chapter-item.current { + color: var(--primary); + background: rgba(212, 165, 116, 0.1); +} + +.chapter-index { + width: 50px; + font-size: var(--font-size-sm); + color: var(--text-muted); + flex-shrink: 0; +} + +.chapter-title { + flex: 1; + font-size: var(--font-size-md); +} + +/* 历史/书签列表项 */ +.history-item, +.bookmark-item { + display: flex; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-md); + position: relative; +} + +.history-item .novel-cover, +.bookmark-item .novel-cover { + width: 60px; + height: 80px; +} + +.history-info, +.bookmark-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.history-chapter, +.bookmark-chapter { + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +.history-time, +.bookmark-note { + font-size: var(--font-size-xs); + color: var(--text-muted); +} + +.item-actions { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); +} + +.item-actions button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); +} + +.item-actions svg { + width: 18px; + height: 18px; + fill: var(--text-muted); +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl) var(--spacing-lg); + text-align: center; + min-height: 300px; +} + +.empty-state svg { + width: 80px; + height: 80px; + fill: var(--text-muted); + margin-bottom: var(--spacing-lg); + opacity: 0.5; +} + +.empty-state h3 { + font-size: var(--font-size-lg); + color: var(--text-secondary); + margin-bottom: var(--spacing-sm); +} + +.empty-state p { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +/* 加载更多 */ +.load-more { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); +} + +.load-more-btn { + padding: var(--spacing-sm) var(--spacing-xl); + background: var(--bg-card); + border-radius: var(--radius-full); + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.loading-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-color); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* 骨架屏 */ +.skeleton { + background: linear-gradient(90deg, var(--bg-hover) 25%, var(--bg-card) 50%, var(--bg-hover) 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: var(--radius-sm); +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton-card { + display: flex; + gap: var(--spacing-md); + padding: var(--spacing-md); +} + +.skeleton-cover { + width: 80px; + height: 110px; +} + +.skeleton-info { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.skeleton-title { + height: 20px; + width: 70%; +} + +.skeleton-text { + height: 14px; + width: 40%; +} + +.skeleton-desc { + height: 40px; + width: 100%; +} + diff --git a/src/main/resources/static/css/pages.css b/src/main/resources/static/css/pages.css new file mode 100644 index 0000000..2ebbd05 --- /dev/null +++ b/src/main/resources/static/css/pages.css @@ -0,0 +1,617 @@ +/* ============================================ + 页面样式 - 书架、发现、历史、书签、搜索、详情 + ============================================ */ + +/* 通用页面容器 */ +.page { + padding: var(--spacing-md); + animation: fadeIn var(--transition-normal) ease; +} + +.page-section { + margin-bottom: var(--spacing-lg); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-md); +} + +.section-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); +} + +.section-more { + font-size: var(--font-size-sm); + color: var(--primary); +} + +/* ========== 书架页面 ========== */ +.shelf-page { + padding-bottom: var(--spacing-xl); +} + +.shelf-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-lg); +} + +.shelf-title { + font-family: var(--font-display); + font-size: var(--font-size-2xl); + color: var(--primary); +} + +.shelf-actions { + display: flex; + gap: var(--spacing-sm); +} + +.shelf-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-md); +} + +@media (min-width: 480px) { + .shelf-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.shelf-item { + position: relative; +} + +.shelf-item .novel-grid-card { + height: 100%; +} + +.shelf-item .unread-badge { + position: absolute; + top: var(--spacing-xs); + right: var(--spacing-xs); + background: #e74c3c; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: var(--radius-full); + z-index: 1; +} + +.shelf-item .reading-progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: var(--bg-hover); +} + +.shelf-item .reading-progress-bar { + height: 100%; + background: var(--primary); + transition: width var(--transition-normal); +} + +/* ========== 发现/探索页面 ========== */ +.explore-page { + padding-bottom: var(--spacing-xl); +} + +.explore-banner { + position: relative; + height: 160px; + margin: calc(-1 * var(--spacing-md)); + margin-bottom: var(--spacing-lg); + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--primary-dark) 100%); + overflow: hidden; +} + +.explore-banner::before { + content: ''; + position: absolute; + inset: 0; + background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); +} + +.explore-banner-content { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + padding: var(--spacing-lg); + padding-top: calc(var(--spacing-lg) + var(--spacing-md)); +} + +.explore-banner h2 { + font-family: var(--font-display); + font-size: var(--font-size-2xl); + color: white; + margin-bottom: var(--spacing-xs); +} + +.explore-banner p { + font-size: var(--font-size-sm); + color: rgba(255, 255, 255, 0.8); +} + +/* 标签云 */ +.tags-cloud { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.tag-btn { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-full); + font-size: var(--font-size-sm); + color: var(--text-secondary); + transition: all var(--transition-fast); +} + +.tag-btn:active { + background: var(--primary); + color: var(--bg-primary); +} + +.tag-btn.active { + background: var(--primary); + color: var(--bg-primary); +} + +/* 小说列表 */ +.novel-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +/* 推荐列表横向滚动 */ +.novel-scroll { + display: flex; + gap: var(--spacing-md); + overflow-x: auto; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + padding-bottom: var(--spacing-sm); + margin: 0 calc(-1 * var(--spacing-md)); + padding-left: var(--spacing-md); + padding-right: var(--spacing-md); +} + +.novel-scroll::-webkit-scrollbar { + display: none; +} + +.novel-scroll .novel-grid-card { + flex-shrink: 0; + width: 120px; + scroll-snap-align: start; +} + +/* ========== 历史页面 ========== */ +.history-page { + padding-bottom: var(--spacing-xl); +} + +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-lg); +} + +.history-title { + font-size: var(--font-size-xl); + font-weight: 600; +} + +.clear-history-btn { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +.history-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.history-date-group { + margin-bottom: var(--spacing-lg); +} + +.history-date { + font-size: var(--font-size-sm); + color: var(--text-muted); + margin-bottom: var(--spacing-sm); + padding-left: var(--spacing-xs); +} + +/* ========== 书签页面 ========== */ +.bookmarks-page { + padding-bottom: var(--spacing-xl); +} + +.bookmarks-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-lg); +} + +.bookmarks-title { + font-size: var(--font-size-xl); + font-weight: 600; +} + +.bookmarks-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.bookmark-item { + cursor: pointer; +} + +.bookmark-item .bookmark-note { + margin-top: var(--spacing-xs); + padding: var(--spacing-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + font-style: italic; +} + +/* ========== 搜索页面 ========== */ +#search-panel { + position: fixed; + inset: 0; + background: var(--bg-primary); + z-index: 200; + display: flex; + flex-direction: column; +} + +.search-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md); + padding-top: calc(var(--spacing-md) + var(--safe-area-top)); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.search-input-wrap { + flex: 1; + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-primary); + border-radius: var(--radius-full); +} + +.search-input-wrap svg { + width: 20px; + height: 20px; + fill: var(--text-muted); + flex-shrink: 0; +} + +.search-input-wrap input { + flex: 1; + font-size: var(--font-size-md); +} + +.search-input-wrap input::placeholder { + color: var(--text-muted); +} + +#search-clear { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +#search-clear svg { + width: 18px; + height: 18px; +} + +.search-tags { + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); + max-height: 40vh; + overflow-y: auto; +} + +.search-section { + margin-bottom: var(--spacing-md); +} + +.search-section:last-child { + margin-bottom: 0; +} + +.search-tags-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-sm); +} + +.search-tags-title { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +.search-tags .clear-history-btn { + font-size: var(--font-size-xs); + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; +} + +.search-tags .tags-cloud { + gap: var(--spacing-xs); +} + +/* 搜索历史标签样式 */ +.history-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding-right: 6px !important; +} + +.history-tag .remove-history { + fill: var(--text-muted); + opacity: 0.6; + cursor: pointer; + flex-shrink: 0; +} + +.history-tag .remove-history:hover { + opacity: 1; + fill: #e74c3c; +} + +.search-results { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); + padding-bottom: calc(var(--spacing-md) + var(--safe-area-bottom)); +} + +.search-hint { + text-align: center; + padding: var(--spacing-xl); + color: var(--text-muted); +} + +/* ========== 小说详情页面 ========== */ +.detail-page { + padding: 0; + /* 底部留出操作栏空间:按钮高度(52px) + padding(32px) + 安全区域 */ + padding-bottom: calc(100px + var(--safe-area-bottom)); +} + +.detail-header { + position: relative; + padding: var(--spacing-lg); + padding-top: var(--spacing-xl); + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); +} + +.detail-header::before { + content: ''; + position: absolute; + inset: 0; + background: var(--bg-secondary); + opacity: 0.8; + z-index: -1; +} + +.detail-cover-wrap { + display: flex; + gap: var(--spacing-lg); +} + +.detail-cover { + width: 120px; + height: 165px; + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-lg); + flex-shrink: 0; +} + +.detail-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-meta { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.detail-title { + font-size: var(--font-size-xl); + font-weight: 700; + line-height: 1.3; +} + +.detail-author { + font-size: var(--font-size-md); + color: var(--text-secondary); +} + +.detail-stats { + display: flex; + gap: var(--spacing-lg); + margin-top: auto; +} + +.detail-stat { + text-align: center; +} + +.detail-stat-value { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--primary); +} + +.detail-stat-label { + font-size: var(--font-size-xs); + color: var(--text-muted); +} + +.detail-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + padding: var(--spacing-md) var(--spacing-lg); +} + +.detail-synopsis { + padding: var(--spacing-md) var(--spacing-lg); +} + +.detail-synopsis h3 { + font-size: var(--font-size-md); + margin-bottom: var(--spacing-sm); +} + +.detail-synopsis p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.8; +} + +.detail-synopsis .expand-btn { + display: inline; + color: var(--primary); + font-size: var(--font-size-sm); +} + +.detail-chapters { + padding: var(--spacing-md) var(--spacing-lg); +} + +.detail-chapters h3 { + font-size: var(--font-size-md); + margin-bottom: var(--spacing-md); + display: flex; + align-items: center; + justify-content: space-between; +} + +.detail-chapters .chapter-count { + font-size: var(--font-size-sm); + color: var(--text-muted); + font-weight: normal; +} + +.chapters-preview { + display: flex; + flex-direction: column; + background: var(--bg-card); + border-radius: var(--radius-md); + overflow: hidden; +} + +.chapters-preview .chapter-item:last-child { + border-bottom: none; +} + +.view-all-chapters { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); + padding: var(--spacing-md); + color: var(--primary); + font-size: var(--font-size-sm); + border-top: 1px solid var(--border-color); +} + +.view-all-chapters svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +/* 详情页底部操作栏 */ +.detail-actions { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-md); + padding-bottom: calc(var(--spacing-md) + var(--safe-area-bottom)); + padding-left: max(var(--spacing-md), var(--safe-area-left)); + padding-right: max(var(--spacing-md), var(--safe-area-right)); + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + z-index: 50; +} + +.detail-actions button { + flex: 1; + padding: var(--spacing-md); + border-radius: var(--radius-md); + font-size: var(--font-size-md); + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + transition: all var(--transition-fast); +} + +.detail-actions svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +.btn-shelf { + background: var(--bg-card); + color: var(--text-primary); +} + +.btn-shelf.in-shelf { + color: var(--primary); +} + +.btn-read { + background: var(--primary); + color: var(--bg-primary); +} + +.btn-read:active { + background: var(--primary-dark); +} + diff --git a/src/main/resources/static/css/reader.css b/src/main/resources/static/css/reader.css new file mode 100644 index 0000000..8eb1daa --- /dev/null +++ b/src/main/resources/static/css/reader.css @@ -0,0 +1,483 @@ +/* ============================================ + 阅读器样式 - 专用于阅读页面 + ============================================ */ + +/* 阅读器容器 */ +.reader-page { + min-height: 100vh; + min-height: 100dvh; + background: var(--reader-bg); + transition: background var(--transition-normal); +} + +.reader-content { + max-width: 720px; + margin: 0 auto; + padding: var(--spacing-lg); + padding-top: calc(var(--spacing-xl) + var(--safe-area-top)); + padding-bottom: calc(100px + var(--safe-area-bottom)); +} + +.reader-chapter-title { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--reader-text); + text-align: center; + margin-bottom: var(--spacing-xl); + padding-bottom: var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.reader-text { + font-size: var(--reader-font-size, 18px); + line-height: var(--reader-line-height, 1.8); + color: var(--reader-text); + text-align: justify; + word-break: break-word; +} + +.reader-text p { + text-indent: 2em; + margin-bottom: 1em; +} + +/* 章节导航 */ +.reader-nav { + display: flex; + justify-content: space-between; + padding: var(--spacing-lg) 0; + margin-top: var(--spacing-xl); + border-top: 1px solid var(--border-color); +} + +.reader-nav button { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: var(--font-size-sm); + transition: all var(--transition-fast); +} + +.reader-nav button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.reader-nav button:not(:disabled):active { + background: var(--bg-hover); +} + +.reader-nav svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +/* 阅读器工具栏 */ +#reader-toolbar { + position: fixed; + inset: 0; + z-index: 150; + pointer-events: none; + opacity: 0; + transition: opacity var(--transition-fast); +} + +#reader-toolbar.visible { + opacity: 1; + pointer-events: auto; +} + +.toolbar-top { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + padding-top: calc(var(--spacing-md) + var(--safe-area-top)); + background: linear-gradient(180deg, rgba(0,0,0,0.8) 0%, transparent 100%); +} + +.toolbar-top #reader-title { + flex: 1; + text-align: center; + font-size: var(--font-size-md); + color: white; + padding: 0 var(--spacing-md); +} + +.toolbar-bottom { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-around; + padding: var(--spacing-md); + padding-bottom: calc(var(--spacing-md) + var(--safe-area-bottom)); + background: linear-gradient(0deg, rgba(0,0,0,0.9) 0%, transparent 100%); +} + +.toolbar-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: var(--spacing-sm); + color: white; + min-width: 60px; +} + +.toolbar-btn svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +.toolbar-btn span { + font-size: var(--font-size-xs); +} + +#reader-prev, +#reader-next { + flex-direction: row; + gap: var(--spacing-xs); +} + +/* 阅读设置面板 */ +#reader-settings-panel { + position: fixed; + inset: 0; + z-index: 200; +} + +#reader-settings-panel .settings-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.settings-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--bg-secondary); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + padding-bottom: var(--safe-area-bottom); + animation: slideInUp var(--transition-normal) ease; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.settings-header span { + font-size: var(--font-size-lg); + font-weight: 600; +} + +#close-settings { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +#close-settings svg { + width: 24px; + height: 24px; + fill: var(--text-secondary); +} + +.settings-group { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.settings-group:last-child { + border-bottom: none; +} + +.settings-group label { + display: block; + font-size: var(--font-size-sm); + color: var(--text-muted); + margin-bottom: var(--spacing-sm); +} + +/* 字体大小控制 */ +.font-size-control { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-lg); +} + +.font-size-control button { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-card); + border-radius: var(--radius-md); + font-size: var(--font-size-md); + font-weight: 600; + transition: background var(--transition-fast); +} + +.font-size-control button:active { + background: var(--bg-hover); +} + +#font-size-value { + min-width: 40px; + text-align: center; + font-size: var(--font-size-lg); +} + +/* 行间距控制 */ +.line-height-control { + display: flex; + gap: var(--spacing-sm); +} + +.line-height-btn { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--text-secondary); + transition: all var(--transition-fast); +} + +.line-height-btn.active { + background: var(--primary); + color: var(--bg-primary); +} + +/* 主题选择 */ +.theme-control { + display: flex; + gap: var(--spacing-md); + justify-content: center; +} + +.theme-btn { + width: 48px; + height: 48px; + padding: 4px; + border-radius: var(--radius-md); + border: 2px solid transparent; + transition: border-color var(--transition-fast); +} + +.theme-btn.active { + border-color: var(--primary); +} + +.theme-preview { + display: block; + width: 100%; + height: 100%; + border-radius: var(--radius-sm); +} + +.theme-preview.light { + background: #f8f5f0; + border: 1px solid #d8d4cc; +} + +.theme-preview.sepia { + background: #f4ecd8; + border: 1px solid #d4c8b0; +} + +.theme-preview.dark { + background: #1a1a2e; + border: 1px solid #3a3a5a; +} + +.theme-preview.amoled { + background: #000000; + border: 1px solid #2a2a2a; +} + +/* 目录面板 */ +#catalog-panel { + position: fixed; + inset: 0; + z-index: 200; +} + +#catalog-panel .catalog-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.catalog-content { + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 85%; + max-width: 360px; + background: var(--bg-secondary); + display: flex; + flex-direction: column; + animation: slideInFromRight var(--transition-normal) ease; +} + +@keyframes slideInFromRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.catalog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + padding-top: calc(var(--spacing-md) + var(--safe-area-top)); + border-bottom: 1px solid var(--border-color); +} + +.catalog-header span { + font-size: var(--font-size-lg); + font-weight: 600; +} + +#close-catalog { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +#close-catalog svg { + width: 24px; + height: 24px; + fill: var(--text-secondary); +} + +.catalog-search { + padding: var(--spacing-sm) var(--spacing-lg); + border-bottom: 1px solid var(--border-color); +} + +.catalog-search input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-primary); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); +} + +.catalog-search input::placeholder { + color: var(--text-muted); +} + +.catalog-list { + flex: 1; + overflow-y: auto; + padding-bottom: var(--safe-area-bottom); +} + +.catalog-list .chapter-item { + padding: var(--spacing-md) var(--spacing-lg); +} + +/* 阅读进度指示器 */ +.reading-progress-indicator { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--bg-hover); + z-index: 160; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.reading-progress-indicator.visible { + opacity: 1; +} + +.reading-progress-indicator .progress-bar { + height: 100%; + background: var(--primary); + transition: width 0.1s linear; +} + +/* 章节加载状态 */ +.chapter-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--spacing-md); +} + +.chapter-loading .loading-spinner { + width: 32px; + height: 32px; +} + +.chapter-loading span { + font-size: var(--font-size-sm); + color: var(--text-muted); +} + +/* 章节错误状态 */ +.chapter-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--spacing-md); + text-align: center; + padding: var(--spacing-lg); +} + +.chapter-error svg { + width: 48px; + height: 48px; + fill: var(--text-muted); +} + +.chapter-error p { + color: var(--text-muted); +} + +.chapter-error button { + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--primary); + color: var(--bg-primary); + border-radius: var(--radius-md); + margin-top: var(--spacing-sm); +} + diff --git a/src/main/resources/static/icon.svg b/src/main/resources/static/icon.svg new file mode 100644 index 0000000..1ff8c68 --- /dev/null +++ b/src/main/resources/static/icon.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js new file mode 100644 index 0000000..a6052bb --- /dev/null +++ b/src/main/resources/static/js/api.js @@ -0,0 +1,289 @@ +/** + * API模块 - 封装所有后端API调用 + */ +const API = (function() { + const BASE_URL = '/api'; + + // 通用请求方法 + async function request(url, options = {}) { + const defaultOptions = { + headers: { + 'Content-Type': 'application/json' + } + }; + + const config = { ...defaultOptions, ...options }; + + try { + const response = await fetch(BASE_URL + url, config); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // 204 No Content + if (response.status === 204) { + return null; + } + + return await response.json(); + } catch (error) { + console.error('API请求失败:', error); + throw error; + } + } + + // GET请求 + function get(url, params = {}) { + const queryString = new URLSearchParams(params).toString(); + const fullUrl = queryString ? `${url}?${queryString}` : url; + return request(fullUrl); + } + + // POST请求 + function post(url, data) { + return request(url, { + method: 'POST', + body: JSON.stringify(data) + }); + } + + // DELETE请求 + function del(url, params = {}) { + const queryString = new URLSearchParams(params).toString(); + const fullUrl = queryString ? `${url}?${queryString}` : url; + return request(fullUrl, { method: 'DELETE' }); + } + + // ========== 小说相关API ========== + + /** + * 搜索小说 + * @param {Object} params - { keyword, tag, page, size } + */ + function searchNovels(params = {}) { + return get('/novels', params); + } + + /** + * 获取小说详情 + * @param {string} novelId + */ + function getNovelDetail(novelId) { + return get(`/novels/${novelId}`); + } + + /** + * 获取章节列表 + * @param {string} novelId + * @param {Object} params - { page, size } + */ + function getChapters(novelId, params = {}) { + return get(`/novels/${novelId}/chapters`, params); + } + + /** + * 获取热门标签 + * @param {number} limit + */ + function getPopularTags(limit = 20) { + return get('/novels/tags/popular', { limit }); + } + + /** + * 随机获取小说 + * @param {number} size 数量 + */ + function getRandomNovels(size = 10) { + return get('/novels/random', { size }); + } + + // ========== 章节相关API ========== + + /** + * 根据章节ID获取内容 + * @param {string} chapterId + */ + function getChapterById(chapterId) { + return get(`/chapters/${chapterId}`); + } + + /** + * 根据小说ID和索引获取章节 + * @param {string} novelId + * @param {number} index + */ + function getChapterByIndex(novelId, index) { + return get(`/chapters/novel/${novelId}`, { index }); + } + + /** + * 获取下一章 + * @param {string} novelId + * @param {number} currentIndex + */ + function getNextChapter(novelId, currentIndex) { + return get(`/chapters/novel/${novelId}/next`, { currentIndex }); + } + + /** + * 获取上一章 + * @param {string} novelId + * @param {number} currentIndex + */ + function getPrevChapter(novelId, currentIndex) { + return get(`/chapters/novel/${novelId}/prev`, { currentIndex }); + } + + /** + * 获取第一章 + * @param {string} novelId + */ + function getFirstChapter(novelId) { + return get(`/chapters/novel/${novelId}/first`); + } + + // ========== 书架相关API ========== + + /** + * 获取书架列表 + * @param {Object} params - { userId, page, size } + */ + function getShelf(params = {}) { + return get('/shelf', params); + } + + /** + * 加入书架 + * @param {Object} data - { userId, novelId, tags } + */ + function addToShelf(data) { + return post('/shelf', data); + } + + /** + * 从书架移除 + * @param {string} novelId + * @param {string} userId + */ + function removeFromShelf(novelId, userId) { + return del(`/shelf/${novelId}`, userId ? { userId } : {}); + } + + /** + * 检查是否在书架 + * @param {string} novelId + * @param {string} userId + */ + function isInShelf(novelId, userId) { + return get(`/shelf/${novelId}/exists`, userId ? { userId } : {}); + } + + // ========== 历史相关API ========== + + /** + * 获取阅读历史 + * @param {Object} params - { userId, page, size } + */ + function getHistory(params = {}) { + return get('/history', params); + } + + /** + * 获取某本书的历史进度 + * @param {string} novelId + * @param {string} userId + */ + function getHistoryByNovel(novelId, userId) { + return get(`/history/novel/${novelId}`, userId ? { userId } : {}); + } + + /** + * 更新阅读进度 + * @param {Object} data - { userId, novelId, chapterId, position } + */ + function updateHistory(data) { + return post('/history', data); + } + + /** + * 删除历史记录 + * @param {string} novelId + * @param {string} userId + */ + function deleteHistory(novelId, userId) { + return del(`/history/novel/${novelId}`, userId ? { userId } : {}); + } + + // ========== 书签相关API ========== + + /** + * 获取书签列表 + * @param {Object} params - { userId, page, size } + */ + function getBookmarks(params = {}) { + return get('/bookmarks', params); + } + + /** + * 获取某本书的书签 + * @param {string} novelId + * @param {string} userId + */ + function getBookmarksByNovel(novelId, userId) { + return get(`/bookmarks/novel/${novelId}`, userId ? { userId } : {}); + } + + /** + * 创建书签 + * @param {Object} data - { userId, novelId, chapterId, position, note } + */ + function createBookmark(data) { + return post('/bookmarks', data); + } + + /** + * 删除书签 + * @param {string} bookmarkId + * @param {string} userId + */ + function deleteBookmark(bookmarkId, userId) { + return del(`/bookmarks/${bookmarkId}`, userId ? { userId } : {}); + } + + // 公开API + return { + // 小说 + searchNovels, + getNovelDetail, + getChapters, + getPopularTags, + getRandomNovels, + + // 章节 + getChapterById, + getChapterByIndex, + getNextChapter, + getPrevChapter, + getFirstChapter, + + // 书架 + getShelf, + addToShelf, + removeFromShelf, + isInShelf, + + // 历史 + getHistory, + getHistoryByNovel, + updateHistory, + deleteHistory, + + // 书签 + getBookmarks, + getBookmarksByNovel, + createBookmark, + deleteBookmark + }; +})(); + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..1a6f0f1 --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,543 @@ +/** + * App模块 - 主应用入口和路由 + */ +const App = (function() { + + // ========== 路由处理 ========== + + function initRouter() { + // 监听hash变化 + $(window).on('hashchange', handleRoute); + + // 初始路由 + handleRoute(); + } + + function handleRoute() { + const hash = window.location.hash || '#/shelf'; + const [path, ...params] = hash.slice(2).split('/'); + + // 更新导航状态 + updateNavState(path); + + // 路由匹配 + switch (path) { + case 'shelf': + showPage('shelf'); + Pages.renderShelf(); + break; + + case 'explore': + showPage('explore'); + Pages.renderExplore(); + break; + + case 'history': + showPage('history'); + Pages.renderHistory(); + break; + + case 'bookmarks': + showPage('bookmarks'); + Pages.renderBookmarks(); + break; + + case 'novel': + if (params[0]) { + showPage('detail'); + Pages.renderDetail(params[0]); + } + break; + + case 'read': + if (params[0]) { + const chapterIndex = parseInt(params[1]) || 0; + showPage('reader'); + Reader.init(params[0], chapterIndex); + } + break; + + default: + window.location.hash = '#/shelf'; + } + } + + function showPage(page) { + // 重置UI状态 + if (page !== 'reader') { + $('#app-header').removeClass('header-hidden'); + $('#app-content').removeClass('reader-mode'); + $('#reader-toolbar').addClass('hidden'); + + // 详情页隐藏底部导航(有自己的操作栏) + if (page === 'detail') { + $('#app-nav').addClass('nav-hidden'); + $('#app-content').addClass('detail-mode'); + } else { + $('#app-nav').removeClass('nav-hidden'); + $('#app-content').removeClass('detail-mode'); + } + } + + // 更新页面标题 + const titles = { + 'shelf': '墨香阁', + 'explore': '发现', + 'history': '阅读历史', + 'bookmarks': '我的书签', + 'detail': '小说详情', + 'reader': '' + }; + + $('#page-title').text(titles[page] || '墨香阁'); + + // 显示/隐藏返回按钮 + if (['detail'].includes(page)) { + $('#btn-back').removeClass('hidden'); + $('#btn-search').addClass('hidden'); + } else if (page === 'reader') { + // 阅读器有自己的返回按钮 + } else { + $('#btn-back').addClass('hidden'); + $('#btn-search').removeClass('hidden'); + } + + // 滚动到顶部 + if (page !== 'reader') { + window.scrollTo(0, 0); + } + } + + function updateNavState(page) { + $('.nav-item').removeClass('active'); + $(`.nav-item[data-page="${page}"]`).addClass('active'); + } + + function navigate(hash) { + window.location.hash = hash; + } + + // ========== 事件绑定 ========== + + function bindEvents() { + // 导航点击 + $('.nav-item').on('click', function(e) { + e.preventDefault(); + const page = $(this).data('page'); + navigate('#/' + page); + }); + + // 返回按钮 + $('#btn-back').on('click', function() { + window.history.back(); + }); + + // 搜索按钮 + $('#btn-search').on('click', function() { + showSearch(); + }); + + // 菜单按钮(暂时不使用) + $('#btn-menu').on('click', function() { + Toast.show('更多功能开发中...'); + }); + + // 搜索面板 + $('#search-back').on('click', hideSearch); + + // 搜索输入 - 实时搜索 + $('#search-input').on('input', debounce(function() { + const keyword = $(this).val().trim(); + if (keyword) { + $('#search-clear').removeClass('hidden'); + doSearch(keyword); + } else { + $('#search-clear').addClass('hidden'); + $('#search-results').html('
输入关键词搜索小说
'); + } + }, 500)); + + // 搜索输入 - 回车确认搜索 + $('#search-input').on('keypress', function(e) { + if (e.which === 13) { + const keyword = $(this).val().trim(); + if (keyword) { + doSearch(keyword); + } + } + }); + + $('#search-clear').on('click', function() { + $('#search-input').val('').focus(); + $(this).addClass('hidden'); + $('#search-results').html('
输入关键词搜索小说
'); + renderSearchTags(); // 重新显示搜索历史和标签 + }); + + // 搜索历史点击 + $(document).on('click', '#search-tags .history-tag', function(e) { + // 检查是否点击了删除按钮 + if ($(e.target).hasClass('remove-history') || $(e.target).closest('.remove-history').length) { + return; + } + const keyword = $(this).data('keyword'); + $('#search-input').val(keyword); + doSearch(keyword); + }); + + // 删除单条搜索历史 + $(document).on('click', '.remove-history', function(e) { + e.stopPropagation(); + const keyword = $(this).data('keyword'); + Store.removeSearchHistory(keyword); + renderSearchTags(); + }); + + // 清空搜索历史 + $(document).on('click', '#clear-search-history', function() { + Store.clearSearchHistory(); + renderSearchTags(); + Toast.show('搜索历史已清空'); + }); + + // 搜索页热门标签点击 + $(document).on('click', '#search-tags .tag-btn:not(.history-tag)', function() { + const tag = $(this).data('tag'); + if (tag) { + $('#search-input').val(''); + Pages.renderSearchByTag(tag); + } + }); + + // 发现页标签点击 + $(document).on('click', '#tags-cloud .tag-btn', function() { + $('.tag-btn').removeClass('active'); + $(this).addClass('active'); + const tag = $(this).data('tag'); + Pages.loadNovels(0, tag); + }); + + // 加载更多 + $(document).on('click', '.load-more-btn', function() { + if (Pages.hasMore()) { + Pages.loadNovels(Pages.currentPage() + 1, null, true); + } + }); + + // 小说卡片点击 + $(document).on('click', '.novel-card, .novel-grid-card, .shelf-item', function(e) { + if ($(e.target).closest('button').length) return; + const novelId = $(this).data('novel-id') || $(this).closest('[data-novel-id]').data('novel-id'); + if (novelId) { + navigate('#/novel/' + novelId); + } + }); + + // 章节点击 + $(document).on('click', '.chapters-preview .chapter-item', function() { + const novelId = $(this).data('novel-id'); + const index = $(this).data('chapter-index'); + navigate('#/read/' + novelId + '/' + index); + }); + + // 查看全部章节 + $(document).on('click', '#view-all-chapters', function() { + // 显示目录面板 + const novelId = $(this).data('novel-id'); + showAllChapters(novelId); + }); + + // 历史记录点击 + $(document).on('click', '.history-item', function(e) { + if ($(e.target).closest('button').length) return; + const novelId = $(this).data('novel-id'); + const index = $(this).data('chapter-index'); + navigate('#/read/' + novelId + '/' + index); + }); + + // 书签点击 + $(document).on('click', '.bookmark-item', function(e) { + if ($(e.target).closest('button').length) return; + const novelId = $(this).data('novel-id'); + const index = $(this).data('chapter-index'); + navigate('#/read/' + novelId + '/' + index); + }); + + // 删除历史 + $(document).on('click', '.delete-history-btn', async function(e) { + e.stopPropagation(); + const novelId = $(this).data('novel-id'); + try { + await API.deleteHistory(novelId, Store.getUserId()); + $(this).closest('.history-item').fadeOut(300, function() { + $(this).remove(); + }); + Toast.show('已删除'); + } catch (error) { + Toast.show('删除失败'); + } + }); + + // 删除书签 + $(document).on('click', '.delete-bookmark-btn', async function(e) { + e.stopPropagation(); + const bookmarkId = $(this).data('bookmark-id'); + try { + await API.deleteBookmark(bookmarkId, Store.getUserId()); + $(this).closest('.bookmark-item').fadeOut(300, function() { + $(this).remove(); + }); + Toast.show('已删除'); + } catch (error) { + Toast.show('删除失败'); + } + }); + + // 清空历史 + $(document).on('click', '#clear-history', function() { + if (confirm('确定要清空所有阅读历史吗?')) { + // 这里需要后端支持批量删除,暂时提示 + Toast.show('功能开发中...'); + } + }); + + // 加入/移出书架 + $(document).on('click', '#btn-shelf', async function() { + const $btn = $(this); + const novelId = $btn.data('novel-id'); + const inShelf = $btn.hasClass('in-shelf'); + + try { + if (inShelf) { + await API.removeFromShelf(novelId, Store.getUserId()); + Store.removeFromShelfCache(novelId); + $btn.removeClass('in-shelf'); + $btn.find('svg path').attr('d', 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z'); + $btn.contents().last()[0].textContent = '加入书架'; + Toast.show('已从书架移除'); + } else { + await API.addToShelf({ userId: Store.getUserId(), novelId }); + Store.addToShelfCache(novelId); + $btn.addClass('in-shelf'); + $btn.find('svg path').attr('d', 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'); + $btn.contents().last()[0].textContent = '已在书架'; + Toast.show('已加入书架'); + } + } catch (error) { + Toast.show('操作失败'); + } + }); + + // 开始/继续阅读 + $(document).on('click', '#btn-read', function() { + const novelId = $(this).data('novel-id'); + const index = $(this).data('chapter-index') || 0; + navigate('#/read/' + novelId + '/' + index); + }); + + // 展开简介 + $(document).on('click', '#expand-synopsis', function() { + const $text = $('#synopsis-text'); + $text.toggleClass('text-clamp-3'); + $(this).text($text.hasClass('text-clamp-3') ? '展开' : '收起'); + }); + } + + // ========== 搜索功能 ========== + + async function showSearch() { + $('#search-panel').removeClass('hidden'); + $('#search-input').val('').focus(); + $('#search-results').html('
输入关键词搜索小说
'); + + // 渲染搜索历史和热门标签 + renderSearchTags(); + } + + async function renderSearchTags() { + let html = ''; + + // 搜索历史 + const history = Store.getSearchHistory(); + if (history.length > 0) { + html += '
'; + html += '
搜索历史'; + html += '
'; + html += '
'; + history.forEach(keyword => { + html += ``; + }); + html += '
'; + } + + // 热门标签 + try { + const tags = await API.getPopularTags(10); + if (tags.length > 0) { + html += '
'; + html += '
热门标签
'; + tags.forEach(tag => { + html += ``; + }); + html += '
'; + } + } catch (error) { + console.error('加载标签失败'); + } + + $('#search-tags').html(html); + } + + function doSearch(keyword) { + if (!keyword || !keyword.trim()) return; + keyword = keyword.trim(); + + // 保存到搜索历史 + Store.addSearchHistory(keyword); + + // 执行搜索 + Pages.renderSearchResults(keyword); + } + + function hideSearch() { + $('#search-panel').addClass('hidden'); + } + + // ========== 全部章节面板 ========== + + async function showAllChapters(novelId) { + $('#catalog-panel').removeClass('hidden'); + const $list = $('#catalog-list'); + $list.html('
'); + + try { + let allChapters = []; + let page = 0; + let hasMore = true; + + while (hasMore) { + const data = await API.getChapters(novelId, { page, size: 100 }); + allChapters = allChapters.concat(data.content || []); + hasMore = !data.last; + page++; + if (page > 50) break; + } + + let html = ''; + allChapters.forEach(chapter => { + html += ` +
+ ${chapter.index + 1} + ${chapter.title} +
+ `; + }); + $list.html(html); + + // 绑定点击事件 + $list.find('.chapter-item').on('click', function() { + const idx = $(this).data('chapter-index'); + $('#catalog-panel').addClass('hidden'); + navigate('#/read/' + novelId + '/' + idx); + }); + + } catch (error) { + $list.html('

加载失败

'); + } + + // 关闭按钮 + $('#close-catalog').off('click').on('click', () => $('#catalog-panel').addClass('hidden')); + $('.catalog-overlay').off('click').on('click', () => $('#catalog-panel').addClass('hidden')); + + // 搜索过滤 + $('#catalog-search-input').off('input').on('input', function() { + const keyword = $(this).val().toLowerCase(); + $list.find('.chapter-item').each(function() { + const title = $(this).find('.chapter-title').text().toLowerCase(); + $(this).toggle(title.includes(keyword)); + }); + }); + } + + // ========== 工具方法 ========== + + function debounce(func, wait) { + let timeout; + return function(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; + } + + // ========== 初始化 ========== + + function init() { + // 初始化主题 + Store.initTheme(); + + // 绑定事件 + bindEvents(); + + // 初始化路由 + initRouter(); + + // 隐藏加载动画 + setTimeout(() => { + $('#app-loader').addClass('fade-out'); + setTimeout(() => { + $('#app-loader').remove(); + }, 400); + }, 500); + + // 注册Service Worker + registerServiceWorker(); + } + + function registerServiceWorker() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') + .then(reg => { + console.log('Service Worker 注册成功'); + }) + .catch(err => { + console.log('Service Worker 注册失败:', err); + }); + } + } + + // 公开API + return { + init, + navigate + }; +})(); + +/** + * Toast提示组件 + */ +const Toast = (function() { + let timer = null; + + function show(message, duration = 2000) { + const $toast = $('#toast'); + $toast.text(message).addClass('show'); + + clearTimeout(timer); + timer = setTimeout(() => { + $toast.removeClass('show'); + }, duration); + } + + return { show }; +})(); + +// 页面加载完成后初始化 +$(document).ready(function() { + App.init(); +}); + diff --git a/src/main/resources/static/js/pages.js b/src/main/resources/static/js/pages.js new file mode 100644 index 0000000..756cf88 --- /dev/null +++ b/src/main/resources/static/js/pages.js @@ -0,0 +1,753 @@ +/** + * Pages模块 - 页面渲染逻辑 + */ +const Pages = (function() { + + // ========== 工具方法 ========== + + // 格式化时间 + function formatTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) return '刚刚'; + if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'; + if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'; + if (diff < 604800000) return Math.floor(diff / 86400000) + '天前'; + + return date.toLocaleDateString('zh-CN'); + } + + // 处理封面图片错误 + function handleCoverError(img) { + img.onerror = null; + img.src = 'data:image/svg+xml,' + encodeURIComponent(` + + + 暂无封面 + + `); + } + + // 清理HTML内容中的换行标签 + function cleanContent(content) { + if (!content) return ''; + return content + .replace(//gi, '\n') + .replace(/ /g, ' ') + .replace(/<[^>]+>/g, '') + .trim(); + } + + // ========== 书架页面 ========== + + async function renderShelf() { + const $content = $('#app-content'); + $content.html(` +
+
+

我的书架

+
+
+ ${renderSkeletonGrid(6)} +
+
+ `); + + try { + const userId = Store.getUserId(); + const data = await API.getShelf({ userId, page: 0, size: 50 }); + const items = data.content || []; + + if (items.length === 0) { + $('#shelf-grid').html(` +
+ +

书架空空如也

+

去发现页面找本好书吧

+
+ `); + return; + } + + // 获取每本书的详情 + const novels = await Promise.all( + items.map(item => API.getNovelDetail(item.novelId).catch(() => null)) + ); + + // 更新书架缓存 + Store.setShelfCache(items.map(i => i.novelId)); + + let html = ''; + novels.forEach((novel, index) => { + if (!novel) return; + const position = Store.getReadingPosition(novel.id); + html += renderShelfItem(novel, position); + }); + + $('#shelf-grid').html(html); + + // 绑定图片错误处理 + $('#shelf-grid img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载书架失败:', error); + Toast.show('加载失败,请重试'); + } + } + + function renderShelfItem(novel, position) { + const progress = position ? Math.min(100, Math.round((position.chapterIndex / (novel.lastChapterIndex || 1)) * 100)) : 0; + return ` +
+
+
+ ${novel.name} +
+
+
${novel.name}
+
${novel.author}
+
+
+ ${progress > 0 ? ` +
+
+
+ ` : ''} +
+ `; + } + + // ========== 发现页面 ========== + + async function renderExplore() { + const $content = $('#app-content'); + $content.html(` +
+
+
+

发现好书

+

探索精彩小说世界

+
+
+ +
+
+

🎲 随机推荐

+ +
+
+ ${renderSkeletonScroll(5)} +
+
+ +
+
+

热门标签

+
+
+
+
+
+
+
+ +
+
+

全部小说

+
+
+ ${renderSkeletonList(5)} +
+
+ +
+
+
+ `); + + // 加载随机推荐 + loadRandomNovels(); + + // 加载标签 + loadTags(); + + // 加载小说列表 + loadNovels(0); + + // 绑定刷新随机推荐按钮 + $('#refresh-random').on('click', function() { + loadRandomNovels(); + }); + } + + // 加载随机推荐 + async function loadRandomNovels() { + $('#random-novels').html(renderSkeletonScroll(5)); + + try { + const novels = await API.getRandomNovels(8); + + if (novels.length === 0) { + $('#random-novels').html('

暂无推荐

'); + return; + } + + let html = ''; + novels.forEach(novel => { + html += ` +
+
+ ${novel.name} +
+
+
${novel.name}
+
${novel.author}
+
+
+ `; + }); + + $('#random-novels').html(html); + + // 绑定图片错误处理 + $('#random-novels img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载随机推荐失败:', error); + $('#random-novels').html('

加载失败

'); + } + } + + // 渲染横向滚动骨架屏 + function renderSkeletonScroll(count) { + let html = ''; + for (let i = 0; i < count; i++) { + html += ` +
+
+
+
+
+
+
+ `; + } + return html; + } + + let currentPage = 0; + let isLoadingMore = false; + let hasMore = true; + + async function loadTags() { + try { + const tags = await API.getPopularTags(15); + let html = ''; + tags.forEach(tag => { + html += ``; + }); + $('#tags-cloud').html(html); + } catch (error) { + console.error('加载标签失败:', error); + } + } + + async function loadNovels(page, tag = null, append = false) { + if (isLoadingMore) return; + isLoadingMore = true; + + if (!append) { + currentPage = 0; + hasMore = true; + $('#novel-list').html(renderSkeletonList(5)); + } + + try { + const params = { page, size: 20 }; + if (tag) params.tag = tag; + + const data = await API.searchNovels(params); + const novels = data.content || []; + hasMore = !data.last; + + let html = ''; + novels.forEach(novel => { + html += renderNovelCard(novel); + }); + + if (append) { + $('#novel-list').append(html); + } else { + $('#novel-list').html(html || '

暂无小说

'); + } + + // 更新加载更多按钮 + if (hasMore) { + $('#load-more').html(''); + } else { + $('#load-more').html('没有更多了'); + } + + currentPage = page; + + // 绑定图片错误处理 + $('#novel-list img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载小说失败:', error); + Toast.show('加载失败,请重试'); + } finally { + isLoadingMore = false; + } + } + + function renderNovelCard(novel) { + const synopsis = cleanContent(novel.synopsis).substring(0, 80); + const tags = (novel.tags || []).slice(0, 2); + return ` +
+
+ ${novel.name} +
+
+
${novel.name}
+
${novel.author}
+
${synopsis}...
+
+ ${tags.map(t => `${t}`).join('')} +
+
+
+ `; + } + + // ========== 历史页面 ========== + + async function renderHistory() { + const $content = $('#app-content'); + $content.html(` +
+
+

阅读历史

+ +
+
+ ${renderSkeletonList(5)} +
+
+ `); + + try { + const userId = Store.getUserId(); + const data = await API.getHistory({ userId, page: 0, size: 50 }); + const items = data.content || []; + + if (items.length === 0) { + $('#history-list').html(` +
+ +

暂无阅读记录

+

开始阅读后会在这里显示

+
+ `); + return; + } + + // 获取小说详情和章节信息 + const historyWithDetails = await Promise.all( + items.map(async (item) => { + try { + const [novel, chapter] = await Promise.all([ + API.getNovelDetail(item.novelId), + API.getChapterById(item.chapterId) + ]); + return { ...item, novel, chapter }; + } catch { + return null; + } + }) + ); + + let html = ''; + historyWithDetails.filter(Boolean).forEach(item => { + html += renderHistoryItem(item); + }); + + $('#history-list').html(html || '

暂无记录

'); + + // 绑定图片错误处理 + $('#history-list img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载历史失败:', error); + Toast.show('加载失败,请重试'); + } + } + + function renderHistoryItem(item) { + return ` +
+
+ ${item.novel?.name || ''} +
+
+
${item.novel?.name || '未知'}
+
${item.chapter?.title || '未知章节'}
+
${formatTime(item.lastReadAt)}
+
+
+ +
+
+ `; + } + + // ========== 书签页面 ========== + + async function renderBookmarks() { + const $content = $('#app-content'); + $content.html(` +
+
+

我的书签

+
+
+ ${renderSkeletonList(5)} +
+
+ `); + + try { + const userId = Store.getUserId(); + const data = await API.getBookmarks({ userId, page: 0, size: 50 }); + const items = data.content || []; + + if (items.length === 0) { + $('#bookmarks-list').html(` +
+ +

暂无书签

+

阅读时点击书签按钮添加

+
+ `); + return; + } + + // 获取小说和章节详情 + const bookmarksWithDetails = await Promise.all( + items.map(async (item) => { + try { + const [novel, chapter] = await Promise.all([ + API.getNovelDetail(item.novelId), + API.getChapterById(item.chapterId) + ]); + return { ...item, novel, chapter }; + } catch { + return null; + } + }) + ); + + let html = ''; + bookmarksWithDetails.filter(Boolean).forEach(item => { + html += renderBookmarkItem(item); + }); + + $('#bookmarks-list').html(html || '

暂无书签

'); + + // 绑定图片错误处理 + $('#bookmarks-list img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载书签失败:', error); + Toast.show('加载失败,请重试'); + } + } + + function renderBookmarkItem(item) { + return ` +
+
+ ${item.novel?.name || ''} +
+
+
${item.novel?.name || '未知'}
+
${item.chapter?.title || '未知章节'}
+ ${item.note ? `
${item.note}
` : ''} +
${formatTime(item.createdAt)}
+
+
+ +
+
+ `; + } + + // ========== 小说详情页面 ========== + + async function renderDetail(novelId) { + const $content = $('#app-content'); + $content.html(` +
+
+
+
+
+
+
+
+
+
+
+ `); + + try { + const novel = await API.getNovelDetail(novelId); + Store.setState('currentNovel', novel); + + // 检查是否在书架 + const inShelf = Store.isInShelfCache(novelId); + + // 获取阅读位置 + const position = Store.getReadingPosition(novelId); + + $content.html(renderDetailPage(novel, inShelf, position)); + + // 加载前几章节 + loadPreviewChapters(novelId); + + // 绑定图片错误处理 + $('.detail-cover img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载详情失败:', error); + Toast.show('加载失败,请重试'); + } + } + + function renderDetailPage(novel, inShelf, position) { + const synopsis = cleanContent(novel.synopsis); + const tags = novel.tags || []; + + return ` +
+
+
+
+ ${novel.name} +
+
+

${novel.name}

+
${novel.author}
+
+
+
${novel.chapterCount || 0}
+
章节
+
+
+
${novel.status === 1 ? '完结' : '连载'}
+
状态
+
+
+
+
+
+ +
+ ${tags.map(t => `${t}`).join('')} +
+ +
+

简介

+

${synopsis}

+ +
+ +
+

+ 目录 + 共${novel.chapterCount || 0}章 +

+
+
+
+ 加载中... +
+
+ +
+ +
+ + +
+
+ `; + } + + async function loadPreviewChapters(novelId) { + try { + const data = await API.getChapters(novelId, { page: 0, size: 5 }); + const chapters = data.content || []; + + let html = ''; + chapters.forEach(chapter => { + html += ` +
+ ${chapter.index + 1} + ${chapter.title} +
+ `; + }); + + $('#chapters-preview').html(html || '

暂无章节

'); + } catch (error) { + console.error('加载章节失败:', error); + } + } + + // ========== 搜索功能 ========== + + async function renderSearchResults(keyword) { + const $results = $('#search-results'); + $results.html('
搜索中...
'); + + try { + const data = await API.searchNovels({ keyword, page: 0, size: 30 }); + const novels = data.content || []; + + if (novels.length === 0) { + $results.html('
未找到相关小说
'); + return; + } + + let html = '
'; + novels.forEach(novel => { + html += renderNovelCard(novel); + }); + html += '
'; + + $results.html(html); + + // 绑定图片错误处理 + $results.find('img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('搜索失败:', error); + $results.html('
搜索失败,请重试
'); + } + } + + async function renderSearchByTag(tag) { + const $results = $('#search-results'); + $results.html('
加载中...
'); + + try { + const data = await API.searchNovels({ tag, page: 0, size: 30 }); + const novels = data.content || []; + + if (novels.length === 0) { + $results.html('
该标签下暂无小说
'); + return; + } + + let html = '
'; + novels.forEach(novel => { + html += renderNovelCard(novel); + }); + html += '
'; + + $results.html(html); + + // 绑定图片错误处理 + $results.find('img').on('error', function() { + handleCoverError(this); + }); + + } catch (error) { + console.error('加载失败:', error); + $results.html('
加载失败,请重试
'); + } + } + + // ========== 骨架屏 ========== + + function renderSkeletonGrid(count) { + let html = ''; + for (let i = 0; i < count; i++) { + html += ` +
+
+
+
+
+
+
+
+
+ `; + } + return html; + } + + function renderSkeletonList(count) { + let html = ''; + for (let i = 0; i < count; i++) { + html += ` +
+
+
+
+
+
+
+
+ `; + } + return html; + } + + // 公开API + return { + renderShelf, + renderExplore, + renderHistory, + renderBookmarks, + renderDetail, + renderSearchResults, + renderSearchByTag, + loadNovels, + currentPage: () => currentPage, + hasMore: () => hasMore + }; +})(); + diff --git a/src/main/resources/static/js/reader.js b/src/main/resources/static/js/reader.js new file mode 100644 index 0000000..53b2d87 --- /dev/null +++ b/src/main/resources/static/js/reader.js @@ -0,0 +1,538 @@ +/** + * Reader模块 - 阅读器逻辑 + */ +const Reader = (function() { + // 当前状态 + let currentNovelId = null; + let currentChapterIndex = 0; + let currentChapter = null; + let toolbarVisible = false; + let toolbarTimer = null; + + // ========== 初始化阅读器 ========== + + async function init(novelId, chapterIndex = 0) { + currentNovelId = novelId; + currentChapterIndex = chapterIndex; + + // 隐藏导航栏 + $('#app-header').addClass('header-hidden'); + $('#app-nav').addClass('nav-hidden'); + $('#app-content').addClass('reader-mode'); + $('#reader-toolbar').removeClass('hidden'); + + // 应用阅读设置 + applySettings(); + + // 加载章节 + await loadChapter(novelId, chapterIndex); + + // 绑定事件 + bindEvents(); + + // 添加进度指示器 + showProgressIndicator(); + } + + // ========== 加载章节 ========== + + async function loadChapter(novelId, index) { + const $content = $('#app-content'); + $content.html(` +
+
+
+
+ 加载中... +
+
+
+ `); + + try { + const chapter = await API.getChapterByIndex(novelId, index); + currentChapter = chapter; + currentChapterIndex = chapter.index; + + // 更新标题 + $('#reader-title').text(chapter.title); + + // 渲染内容 + renderChapter(chapter); + + // 保存阅读位置 + saveReadingProgress(); + + // 更新历史记录 + updateHistory(novelId, chapter.id); + + // 恢复滚动位置 + restoreScrollPosition(); + + } catch (error) { + console.error('加载章节失败:', error); + $content.html(` +
+
+
+ +

加载失败

+ +
+
+
+ `); + } + } + + function renderChapter(chapter) { + // 处理内容格式 + let content = chapter.content || ''; + content = content + .replace(//gi, '

') + .replace(/ /g, ' '); + + // 如果没有段落标签,按换行分段 + if (!content.includes('

')) { + content = '

' + content.split('\n').filter(p => p.trim()).join('

') + '

'; + } + + const $content = $('#app-content'); + $content.html(` +
+
+

${chapter.title}

+
${content}
+
+ + +
+
+
+ `); + + // 应用设置 + applySettings(); + } + + // ========== 章节导航 ========== + + async function nextChapter() { + hideToolbar(); + try { + const chapter = await API.getNextChapter(currentNovelId, currentChapterIndex); + if (chapter) { + currentChapter = chapter; + currentChapterIndex = chapter.index; + renderChapter(chapter); + scrollToTop(); + saveReadingProgress(); + updateHistory(currentNovelId, chapter.id); + $('#reader-title').text(chapter.title); + } else { + Toast.show('已是最新章节'); + } + } catch (error) { + Toast.show('加载失败'); + } + } + + async function prevChapter() { + if (currentChapterIndex <= 0) { + Toast.show('已是第一章'); + return; + } + hideToolbar(); + try { + const chapter = await API.getPrevChapter(currentNovelId, currentChapterIndex); + if (chapter) { + currentChapter = chapter; + currentChapterIndex = chapter.index; + renderChapter(chapter); + scrollToTop(); + saveReadingProgress(); + updateHistory(currentNovelId, chapter.id); + $('#reader-title').text(chapter.title); + } else { + Toast.show('已是第一章'); + } + } catch (error) { + Toast.show('加载失败'); + } + } + + async function goToChapter(index) { + hideToolbar(); + hideCatalog(); + await loadChapter(currentNovelId, index); + scrollToTop(); + } + + function reload() { + loadChapter(currentNovelId, currentChapterIndex); + } + + // ========== 工具栏 ========== + + function toggleToolbar() { + if (toolbarVisible) { + hideToolbar(); + } else { + showToolbar(); + } + } + + function showToolbar() { + toolbarVisible = true; + $('#reader-toolbar').addClass('visible'); + $('.reading-progress-indicator').addClass('visible'); + + // 自动隐藏 + clearTimeout(toolbarTimer); + toolbarTimer = setTimeout(() => { + hideToolbar(); + }, 5000); + } + + function hideToolbar() { + toolbarVisible = false; + $('#reader-toolbar').removeClass('visible'); + $('.reading-progress-indicator').removeClass('visible'); + clearTimeout(toolbarTimer); + } + + // ========== 设置面板 ========== + + function showSettings() { + $('#reader-settings-panel').removeClass('hidden'); + hideToolbar(); + } + + function hideSettings() { + $('#reader-settings-panel').addClass('hidden'); + } + + function applySettings() { + const fontSize = Store.getFontSize(); + const lineHeight = Store.getLineHeight(); + + $('.reader-text').css({ + '--reader-font-size': fontSize + 'px', + 'font-size': fontSize + 'px', + '--reader-line-height': lineHeight, + 'line-height': lineHeight + }); + + $('#font-size-value').text(fontSize); + + // 更新行间距按钮状态 + $('.line-height-btn').removeClass('active'); + $(`.line-height-btn[data-value="${lineHeight}"]`).addClass('active'); + + // 更新主题按钮状态 + const theme = Store.getTheme(); + $('.theme-btn').removeClass('active'); + $(`.theme-btn[data-theme="${theme}"]`).addClass('active'); + } + + function changeFontSize(delta) { + const current = Store.getFontSize(); + const newSize = Store.setFontSize(current + delta); + applySettings(); + } + + function changeLineHeight(value) { + Store.setLineHeight(parseFloat(value)); + applySettings(); + } + + function changeTheme(theme) { + Store.setTheme(theme); + applySettings(); + } + + // ========== 目录面板 ========== + + async function showCatalog() { + $('#catalog-panel').removeClass('hidden'); + hideToolbar(); + + // 加载章节列表 + const $list = $('#catalog-list'); + $list.html('
'); + + try { + // 分批加载所有章节 + let allChapters = []; + let page = 0; + let hasMore = true; + + while (hasMore) { + const data = await API.getChapters(currentNovelId, { page, size: 100 }); + allChapters = allChapters.concat(data.content || []); + hasMore = !data.last; + page++; + + // 限制最多加载50页 + if (page > 50) break; + } + + renderCatalogList(allChapters); + + // 滚动到当前章节 + setTimeout(() => { + const $current = $list.find('.chapter-item.current'); + if ($current.length) { + $current[0].scrollIntoView({ block: 'center' }); + } + }, 100); + + } catch (error) { + console.error('加载目录失败:', error); + $list.html('

加载失败

'); + } + } + + function renderCatalogList(chapters) { + let html = ''; + chapters.forEach(chapter => { + const isCurrent = chapter.index === currentChapterIndex; + html += ` +
+ ${chapter.index + 1} + ${chapter.title} +
+ `; + }); + $('#catalog-list').html(html); + } + + function hideCatalog() { + $('#catalog-panel').addClass('hidden'); + } + + function filterCatalog(keyword) { + const $items = $('#catalog-list .chapter-item'); + keyword = keyword.toLowerCase(); + + $items.each(function() { + const title = $(this).find('.chapter-title').text().toLowerCase(); + $(this).toggle(title.includes(keyword)); + }); + } + + // ========== 书签 ========== + + function showBookmarkModal() { + $('#bookmark-modal').removeClass('hidden'); + $('#bookmark-note').val('').focus(); + hideToolbar(); + } + + function hideBookmarkModal() { + $('#bookmark-modal').addClass('hidden'); + } + + async function addBookmark(note) { + if (!currentChapter) return; + + try { + await API.createBookmark({ + userId: Store.getUserId(), + novelId: currentNovelId, + chapterId: currentChapter.id, + position: window.scrollY, + note: note || '' + }); + + Toast.show('书签已添加'); + hideBookmarkModal(); + } catch (error) { + console.error('添加书签失败:', error); + Toast.show('添加失败'); + } + } + + // ========== 阅读进度 ========== + + function saveReadingProgress() { + if (!currentNovelId) return; + + Store.setReadingPosition(currentNovelId, { + chapterIndex: currentChapterIndex, + scrollTop: window.scrollY + }); + } + + function restoreScrollPosition() { + const position = Store.getReadingPosition(currentNovelId); + if (position && position.chapterIndex === currentChapterIndex && position.scrollTop) { + setTimeout(() => { + window.scrollTo(0, position.scrollTop); + }, 100); + } + } + + async function updateHistory(novelId, chapterId) { + try { + await API.updateHistory({ + userId: Store.getUserId(), + novelId: novelId, + chapterId: chapterId, + position: window.scrollY + }); + } catch (error) { + console.error('更新历史失败:', error); + } + } + + function showProgressIndicator() { + if ($('.reading-progress-indicator').length === 0) { + $('body').append('
'); + } + } + + function updateProgressIndicator() { + const scrollTop = window.scrollY; + const docHeight = document.documentElement.scrollHeight - window.innerHeight; + const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0; + $('.reading-progress-indicator .progress-bar').css('width', progress + '%'); + } + + // ========== 事件绑定 ========== + + function bindEvents() { + // 点击内容区域切换工具栏 + $(document).off('click.reader').on('click.reader', '.reader-page', function(e) { + // 排除按钮点击 + if ($(e.target).closest('button, a').length) return; + toggleToolbar(); + }); + + // 滚动事件 + let scrollTimer; + $(window).off('scroll.reader').on('scroll.reader', function() { + // 更新进度条 + updateProgressIndicator(); + + // 节流保存进度 + clearTimeout(scrollTimer); + scrollTimer = setTimeout(() => { + saveReadingProgress(); + }, 500); + }); + + // 工具栏按钮 + $('#reader-back').off('click').on('click', exit); + $('#reader-prev').off('click').on('click', prevChapter); + $('#reader-next').off('click').on('click', nextChapter); + $('#reader-catalog').off('click').on('click', showCatalog); + $('#reader-settings').off('click').on('click', showSettings); + $('#reader-bookmark').off('click').on('click', showBookmarkModal); + + // 章节内导航按钮 + $(document).off('click.navprev').on('click.navprev', '#nav-prev', prevChapter); + $(document).off('click.navnext').on('click.navnext', '#nav-next', nextChapter); + + // 设置面板 + $('#close-settings').off('click').on('click', hideSettings); + $('.settings-overlay').off('click').on('click', hideSettings); + + $('#font-decrease').off('click').on('click', () => changeFontSize(-2)); + $('#font-increase').off('click').on('click', () => changeFontSize(2)); + + $('.line-height-btn').off('click').on('click', function() { + changeLineHeight($(this).data('value')); + }); + + $('.theme-btn').off('click').on('click', function() { + changeTheme($(this).data('theme')); + }); + + // 目录面板 + $('#close-catalog').off('click').on('click', hideCatalog); + $('.catalog-overlay').off('click').on('click', hideCatalog); + + $('#catalog-search-input').off('input').on('input', function() { + filterCatalog($(this).val()); + }); + + $(document).off('click.catalogitem').on('click.catalogitem', '#catalog-list .chapter-item', function() { + const index = $(this).data('chapter-index'); + goToChapter(index); + }); + + // 书签弹窗 + $('#bookmark-modal .modal-overlay, #bookmark-modal .modal-close, #bookmark-modal .btn-cancel') + .off('click').on('click', hideBookmarkModal); + + $('#bookmark-modal .btn-confirm').off('click').on('click', function() { + const note = $('#bookmark-note').val(); + addBookmark(note); + }); + } + + function unbindEvents() { + $(document).off('click.reader'); + $(window).off('scroll.reader'); + $(document).off('click.navprev'); + $(document).off('click.navnext'); + $(document).off('click.catalogitem'); + } + + // ========== 退出阅读器 ========== + + function exit() { + // 保存进度 + saveReadingProgress(); + + // 显示导航栏 + $('#app-header').removeClass('header-hidden'); + $('#app-nav').removeClass('nav-hidden'); + $('#app-content').removeClass('reader-mode'); + $('#reader-toolbar').addClass('hidden').removeClass('visible'); + + // 隐藏面板 + hideSettings(); + hideCatalog(); + hideBookmarkModal(); + + // 移除进度指示器 + $('.reading-progress-indicator').remove(); + + // 解绑事件 + unbindEvents(); + + // 返回详情页 + if (currentNovelId) { + window.location.hash = '#/novel/' + currentNovelId; + } else { + window.location.hash = '#/shelf'; + } + } + + function scrollToTop() { + window.scrollTo({ top: 0, behavior: 'instant' }); + } + + // 公开API + return { + init, + exit, + reload, + nextChapter, + prevChapter, + goToChapter, + showCatalog, + showSettings, + showBookmarkModal + }; +})(); + diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js new file mode 100644 index 0000000..0308e59 --- /dev/null +++ b/src/main/resources/static/js/store.js @@ -0,0 +1,240 @@ +/** + * Store模块 - 状态管理和本地存储 + */ +const Store = (function() { + // 存储键名 + const KEYS = { + USER_ID: 'novel_reader_user_id', + THEME: 'novel_reader_theme', + FONT_SIZE: 'novel_reader_font_size', + LINE_HEIGHT: 'novel_reader_line_height', + READING_POSITION: 'novel_reader_position_', + SHELF_CACHE: 'novel_reader_shelf_cache', + HISTORY_CACHE: 'novel_reader_history_cache', + SEARCH_HISTORY: 'novel_reader_search_history' + }; + + // 搜索历史最大数量 + const MAX_SEARCH_HISTORY = 10; + + // 默认设置 + const DEFAULTS = { + theme: 'dark', + fontSize: 18, + lineHeight: 1.8 + }; + + // ========== 基础存储方法 ========== + + function get(key, defaultValue = null) { + try { + const value = localStorage.getItem(key); + if (value === null) return defaultValue; + return JSON.parse(value); + } catch (e) { + return defaultValue; + } + } + + function set(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error('存储失败:', e); + } + } + + function remove(key) { + localStorage.removeItem(key); + } + + // ========== 用户ID管理 ========== + + function getUserId() { + let userId = get(KEYS.USER_ID); + if (!userId) { + // 生成简单的UUID + userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + set(KEYS.USER_ID, userId); + } + return userId; + } + + // ========== 主题设置 ========== + + function getTheme() { + return get(KEYS.THEME, DEFAULTS.theme); + } + + function setTheme(theme) { + set(KEYS.THEME, theme); + document.documentElement.setAttribute('data-theme', theme); + } + + function initTheme() { + const theme = getTheme(); + document.documentElement.setAttribute('data-theme', theme); + } + + // ========== 字体大小设置 ========== + + function getFontSize() { + return get(KEYS.FONT_SIZE, DEFAULTS.fontSize); + } + + function setFontSize(size) { + size = Math.max(12, Math.min(28, size)); + set(KEYS.FONT_SIZE, size); + return size; + } + + // ========== 行间距设置 ========== + + function getLineHeight() { + return get(KEYS.LINE_HEIGHT, DEFAULTS.lineHeight); + } + + function setLineHeight(height) { + set(KEYS.LINE_HEIGHT, height); + } + + // ========== 阅读位置缓存 ========== + + function getReadingPosition(novelId) { + return get(KEYS.READING_POSITION + novelId, null); + } + + function setReadingPosition(novelId, position) { + // position: { chapterIndex, scrollTop } + set(KEYS.READING_POSITION + novelId, { + ...position, + timestamp: Date.now() + }); + } + + // ========== 书架缓存 ========== + + function getShelfCache() { + return get(KEYS.SHELF_CACHE, []); + } + + function setShelfCache(items) { + set(KEYS.SHELF_CACHE, items); + } + + function addToShelfCache(novelId) { + const cache = getShelfCache(); + if (!cache.includes(novelId)) { + cache.push(novelId); + setShelfCache(cache); + } + } + + function removeFromShelfCache(novelId) { + const cache = getShelfCache(); + const index = cache.indexOf(novelId); + if (index > -1) { + cache.splice(index, 1); + setShelfCache(cache); + } + } + + function isInShelfCache(novelId) { + return getShelfCache().includes(novelId); + } + + // ========== 搜索历史 ========== + + function getSearchHistory() { + return get(KEYS.SEARCH_HISTORY, []); + } + + function addSearchHistory(keyword) { + if (!keyword || !keyword.trim()) return; + keyword = keyword.trim(); + + let history = getSearchHistory(); + // 移除已存在的相同关键词 + history = history.filter(item => item !== keyword); + // 添加到最前面 + history.unshift(keyword); + // 限制数量 + if (history.length > MAX_SEARCH_HISTORY) { + history = history.slice(0, MAX_SEARCH_HISTORY); + } + set(KEYS.SEARCH_HISTORY, history); + } + + function removeSearchHistory(keyword) { + let history = getSearchHistory(); + history = history.filter(item => item !== keyword); + set(KEYS.SEARCH_HISTORY, history); + } + + function clearSearchHistory() { + set(KEYS.SEARCH_HISTORY, []); + } + + // ========== 应用状态 ========== + + // 当前状态(不持久化) + const state = { + currentNovel: null, + currentChapter: null, + isLoading: false, + toolbarVisible: false + }; + + function getState(key) { + return state[key]; + } + + function setState(key, value) { + state[key] = value; + } + + // 公开API + return { + // 基础 + get, + set, + remove, + + // 用户 + getUserId, + + // 设置 + getTheme, + setTheme, + initTheme, + getFontSize, + setFontSize, + getLineHeight, + setLineHeight, + + // 阅读位置 + getReadingPosition, + setReadingPosition, + + // 书架缓存 + getShelfCache, + setShelfCache, + addToShelfCache, + removeFromShelfCache, + isInShelfCache, + + // 搜索历史 + getSearchHistory, + addSearchHistory, + removeSearchHistory, + clearSearchHistory, + + // 状态 + getState, + setState + }; +})(); + +// 初始化主题 +Store.initTheme(); + diff --git a/src/main/resources/static/manifest.json b/src/main/resources/static/manifest.json new file mode 100644 index 0000000..cd1aca7 --- /dev/null +++ b/src/main/resources/static/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "墨香阁 - 小说阅读", + "short_name": "墨香阁", + "description": "个人电子书阅读应用", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["books", "entertainment"], + "lang": "zh-CN", + "dir": "ltr", + "scope": "/", + "prefer_related_applications": false +} + diff --git a/src/main/resources/static/sw.js b/src/main/resources/static/sw.js new file mode 100644 index 0000000..8e18fd7 --- /dev/null +++ b/src/main/resources/static/sw.js @@ -0,0 +1,199 @@ +/** + * Service Worker - PWA离线支持 + */ + +const CACHE_NAME = 'novel-reader-v1'; +const STATIC_CACHE = 'novel-reader-static-v1'; +const DYNAMIC_CACHE = 'novel-reader-dynamic-v1'; + +// 需要预缓存的静态资源 +const STATIC_ASSETS = [ + '/', + '/css/base.css', + '/css/components.css', + '/css/pages.css', + '/css/reader.css', + '/js/api.js', + '/js/store.js', + '/js/pages.js', + '/js/reader.js', + '/js/app.js', + '/manifest.json', + 'https://code.jquery.com/jquery-3.7.1.min.js', + 'https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&family=Noto+Serif+SC:wght@400;600;700&display=swap' +]; + +// 安装事件 - 预缓存静态资源 +self.addEventListener('install', event => { + console.log('[SW] Installing...'); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then(cache => { + console.log('[SW] Pre-caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => { + console.log('[SW] Install completed'); + return self.skipWaiting(); + }) + .catch(err => { + console.error('[SW] Install failed:', err); + }) + ); +}); + +// 激活事件 - 清理旧缓存 +self.addEventListener('activate', event => { + console.log('[SW] Activating...'); + + event.waitUntil( + caches.keys() + .then(keys => { + return Promise.all( + keys + .filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE) + .map(key => { + console.log('[SW] Removing old cache:', key); + return caches.delete(key); + }) + ); + }) + .then(() => { + console.log('[SW] Activate completed'); + return self.clients.claim(); + }) + ); +}); + +// 请求拦截 +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // 跳过非GET请求 + if (request.method !== 'GET') { + return; + } + + // API请求 - 网络优先,失败时使用缓存 + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirst(request)); + return; + } + + // 静态资源 - 缓存优先 + if (isStaticAsset(url)) { + event.respondWith(cacheFirst(request)); + return; + } + + // 页面请求 - 网络优先 + if (request.mode === 'navigate') { + event.respondWith(networkFirst(request)); + return; + } + + // 其他请求 - 网络优先 + event.respondWith(networkFirst(request)); +}); + +// 判断是否为静态资源 +function isStaticAsset(url) { + const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2']; + return staticExtensions.some(ext => url.pathname.endsWith(ext)); +} + +// 缓存优先策略 +async function cacheFirst(request) { + const cachedResponse = await caches.match(request); + + if (cachedResponse) { + // 后台更新缓存 + updateCache(request); + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + const cache = await caches.open(STATIC_CACHE); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + console.error('[SW] Cache first failed:', error); + return new Response('Offline', { status: 503 }); + } +} + +// 网络优先策略 +async function networkFirst(request) { + try { + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + const cache = await caches.open(DYNAMIC_CACHE); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + console.log('[SW] Network failed, trying cache:', request.url); + + const cachedResponse = await caches.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + // 如果是页面请求,返回离线页面 + if (request.mode === 'navigate') { + const offlinePage = await caches.match('/'); + if (offlinePage) { + return offlinePage; + } + } + + return new Response('Offline', { status: 503 }); + } +} + +// 后台更新缓存 +async function updateCache(request) { + try { + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + const cache = await caches.open(STATIC_CACHE); + cache.put(request, networkResponse); + } + } catch (error) { + // 忽略后台更新错误 + } +} + +// 推送通知(预留) +self.addEventListener('push', event => { + if (event.data) { + const data = event.data.json(); + + self.registration.showNotification(data.title, { + body: data.body, + icon: '/icons/icon-192.png', + badge: '/icons/icon-72.png' + }); + } +}); + +// 通知点击处理 +self.addEventListener('notificationclick', event => { + event.notification.close(); + + event.waitUntil( + clients.openWindow('/') + ); +}); + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..9ab6b17 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,302 @@ + + + + + + + + + 墨香阁 - 小说阅读 + + + + + + + + + + + + + + +
+
+
+ 墨香阁 +
+
+ + +
+ +
+
+ +
+

墨香阁

+
+ + +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + +