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 += '
';
+ }
+
+ // 热门标签
+ 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.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.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.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.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.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 `
+
+
+
+
+ ${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 @@
+
+
+
+
+
+
+
+
+ 墨香阁 - 小说阅读
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 18
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+