This commit is contained in:
irony 2025-12-06 18:19:46 +08:00
commit a20550422f
53 changed files with 6587 additions and 0 deletions

28
.dockerignore Normal file
View File

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

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.idea/
target/
*.tar

158
DEPLOY.md Normal file
View File

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

33
Dockerfile Normal file
View File

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

50
Dockerfile.china Normal file
View File

@ -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 '<?xml version="1.0" encoding="UTF-8"?>\n\
<settings>\n\
<mirrors>\n\
<mirror>\n\
<id>aliyun</id>\n\
<mirrorOf>central</mirrorOf>\n\
<url>https://maven.aliyun.com/repository/public</url>\n\
</mirror>\n\
</mirrors>\n\
</settings>' > /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"]

21
Dockerfile.simple Normal file
View File

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

39
docker-compose.yml Normal file
View File

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

92
pom.xml Normal file
View File

@ -0,0 +1,92 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.novelreader</groupId>
<artifactId>server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>novel-reader-server</name>
<properties>
<java.version>1.8</java.version>
<spring.boot.version>2.7.18</spring.boot.version>
<!-- <maven.compiler.release>8</maven.compiler.release>-->
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>com.novelreader.NovelReaderApplication</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<!--<configuration>
<release>${maven.compiler.release}</release>
</configuration>-->
</plugin>
</plugins>
</build>
</project>

View File

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

View File

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

View File

@ -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<Page<Bookmark>> 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<List<Bookmark>> listByNovel(@PathVariable String novelId,
@RequestParam(required = false) String userId) {
return ResponseEntity.ok(bookmarkService.getBookmarksByNovel(userId, novelId));
}
@PostMapping
@Operation(summary = "创建书签")
public ResponseEntity<Bookmark> 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<Void> delete(@PathVariable String bookmarkId,
@RequestParam(required = false) String userId) {
bookmarkService.deleteBookmark(userId, bookmarkId);
return ResponseEntity.noContent().build();
}
}

View File

@ -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<ChapterContent> getById(@PathVariable String chapterId) {
Optional<ChapterContent> content = chapterService.getChapterContent(chapterId);
return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@GetMapping("/novel/{novelId}")
@Operation(summary = "按小说ID和索引获取章节")
public ResponseEntity<ChapterContent> getByNovelAndIndex(@PathVariable String novelId,
@RequestParam Integer index) {
Optional<ChapterContent> content = chapterService.getChapterByNovelIdAndIndex(novelId, index);
return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@GetMapping("/novel/{novelId}/next")
@Operation(summary = "获取下一章")
public ResponseEntity<ChapterContent> getNext(@PathVariable String novelId,
@RequestParam Integer currentIndex) {
Optional<ChapterContent> content = chapterService.getNextChapter(novelId, currentIndex);
return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@GetMapping("/novel/{novelId}/prev")
@Operation(summary = "获取上一章")
public ResponseEntity<ChapterContent> getPrev(@PathVariable String novelId,
@RequestParam Integer currentIndex) {
Optional<ChapterContent> content = chapterService.getPrevChapter(novelId, currentIndex);
return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@GetMapping("/novel/{novelId}/first")
@Operation(summary = "获取第一章")
public ResponseEntity<ChapterContent> getFirst(@PathVariable String novelId) {
Optional<ChapterContent> content = chapterService.getFirstChapter(novelId);
return content.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
}

View File

@ -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<Page<History>> 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<History> getByNovel(@PathVariable String novelId,
@RequestParam(required = false) String userId) {
Optional<History> history = historyService.getHistoryByNovel(userId, novelId);
return history.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "更新阅读进度")
public ResponseEntity<History> 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<Void> delete(@PathVariable String novelId,
@RequestParam(required = false) String userId) {
historyService.deleteHistory(userId, novelId);
return ResponseEntity.noContent().build();
}
}

View File

@ -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<Page<NovelBrief>> 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<NovelDetail> getNovelDetail(@PathVariable String id) {
Optional<NovelDetail> detail = novelService.getNovelDetail(id);
return detail.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{id}/chapters")
@Operation(summary = "获取章节列表")
public ResponseEntity<Page<ChapterHead>> 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<List<String>> getPopularTags(@RequestParam(defaultValue = "20") int limit) {
return ResponseEntity.ok(novelService.getPopularTags(limit));
}
@GetMapping("/random")
@Operation(summary = "随机获取小说", description = "从数据库中随机获取指定数量的小说")
public ResponseEntity<List<NovelBrief>> getRandomNovels(@RequestParam(defaultValue = "10") int size) {
return ResponseEntity.ok(novelService.getRandomNovels(size));
}
}

View File

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

View File

@ -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<Page<ShelfItem>> 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<ShelfItem> 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<Void> delete(@PathVariable String novelId,
@RequestParam(required = false) String userId) {
shelfService.removeFromShelf(userId, novelId);
return ResponseEntity.noContent().build();
}
@GetMapping("/{novelId}/exists")
@Operation(summary = "检测是否在书架")
public ResponseEntity<Boolean> exists(@PathVariable String novelId,
@RequestParam(required = false) String userId) {
return ResponseEntity.ok(shelfService.isInShelf(userId, novelId));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> tags;
private Long chapterCount;
private Integer lastChapterIndex;
}

View File

@ -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<String> tags;
private String userId;
}

View File

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

View File

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

View File

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

View File

@ -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<String> tags;
@Field("createdAt")
private Instant createdAt;
@Field("updatedAt")
private Instant updatedAt;
}

View File

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

View File

@ -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<Bookmark, String> {
Page<Bookmark> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable);
List<Bookmark> findByUserIdAndNovelId(String userId, String novelId);
Optional<Bookmark> findByUserIdAndNovelIdAndChapterId(String userId, String novelId, String chapterId);
void deleteByUserIdAndId(String userId, String id);
}

View File

@ -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<ChapterDO, String> {
Page<ChapterDO> findByNovelIdOrderByIndexAsc(String novelId, Pageable pageable);
Optional<ChapterDO> findByNovelIdAndIndex(String novelId, Integer index);
Optional<ChapterDO> findFirstByNovelIdOrderByIndexAsc(String novelId);
Optional<ChapterDO> findFirstByNovelIdOrderByIndexDesc(String novelId);
long countByNovelId(String novelId);
}

View File

@ -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<History, String> {
Page<History> findByUserIdOrderByLastReadAtDesc(String userId, Pageable pageable);
Optional<History> findByUserIdAndNovelId(String userId, String novelId);
void deleteByUserIdAndNovelId(String userId, String novelId);
}

View File

@ -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<NovelDO, String> {
@Query("{ $or: [ { 'name': { $regex: ?0, $options: 'i' } }, { 'author': { $regex: ?0, $options: 'i' } } ] }")
Page<NovelDO> findByNameOrAuthorContainingIgnoreCase(String keyword, Pageable pageable);
@Query("{ 'tags': { $in: ?0 } }")
Page<NovelDO> findByTagsIn(List<String> tags, Pageable pageable);
@Query("{ $and: [ { $or: [ { 'name': { $regex: ?0, $options: 'i' } }, { 'author': { $regex: ?0, $options: 'i' } } ] }, { 'tags': { $in: ?1 } } ] }")
Page<NovelDO> findByNameOrAuthorAndTags(String keyword, List<String> tags, Pageable pageable);
Page<NovelDO> findAll(Pageable pageable);
}

View File

@ -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<ShelfItem, String> {
Page<ShelfItem> findByUserIdOrderByAddedAtDesc(String userId, Pageable pageable);
Optional<ShelfItem> findByUserIdAndNovelId(String userId, String novelId);
boolean existsByUserIdAndNovelId(String userId, String novelId);
void deleteByUserIdAndNovelId(String userId, String novelId);
}

View File

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

View File

@ -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<ChapterContent> getChapterContent(String chapterId) {
return chapterRepository.findById(chapterId).map(this::toContent);
}
public Optional<ChapterContent> getChapterByNovelIdAndIndex(String novelId, Integer index) {
return chapterRepository.findByNovelIdAndIndex(novelId, index).map(this::toContent);
}
public Optional<ChapterContent> getNextChapter(String novelId, Integer currentIndex) {
return chapterRepository.findByNovelIdAndIndex(novelId, currentIndex + 1)
.map(this::toContent);
}
public Optional<ChapterContent> getPrevChapter(String novelId, Integer currentIndex) {
return chapterRepository.findByNovelIdAndIndex(novelId, currentIndex - 1)
.map(this::toContent);
}
public Optional<ChapterContent> 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;
}
}

View File

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

View File

@ -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<NovelBrief> searchNovels(String keyword, String tag, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
Page<NovelDO> novels;
List<String> 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<NovelDetail> 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<ChapterHead> 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<String> 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<TagCount> 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<NovelBrief> getRandomNovels(int size) {
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.sample(size)
);
AggregationResults<NovelDO> 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;
}
}

View File

@ -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<ShelfItem> 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<String> tags) {
String uid = userId != null ? userId : DEFAULT_USER_ID;
Optional<ShelfItem> 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);
}
}

View File

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

View File

@ -0,0 +1,9 @@
/* ============================================
主样式入口 - 导入所有CSS模块
============================================ */
@import url('base.css');
@import url('components.css');
@import url('pages.css');
@import url('reader.css');

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#16213e"/>
</linearGradient>
<linearGradient id="gold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#e8c9a8"/>
<stop offset="100%" style="stop-color:#d4a574"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="512" height="512" rx="100" fill="url(#bg)"/>
<!-- 书本图标 -->
<g transform="translate(256, 256)">
<!-- 左页 -->
<path d="M-20,-120 L-20,120 Q-80,100 -140,120 L-140,-100 Q-80,-120 -20,-100 Z"
fill="url(#gold)" opacity="0.9"/>
<!-- 右页 -->
<path d="M20,-120 L20,120 Q80,100 140,120 L140,-100 Q80,-120 20,-100 Z"
fill="url(#gold)" opacity="0.7"/>
<!-- 书脊 -->
<rect x="-20" y="-120" width="40" height="240" fill="#b8956a"/>
<!-- 装饰线条 -->
<line x1="-100" y1="-60" x2="-40" y2="-60" stroke="#1a1a2e" stroke-width="4" opacity="0.3"/>
<line x1="-100" y1="-20" x2="-60" y2="-20" stroke="#1a1a2e" stroke-width="4" opacity="0.3"/>
<line x1="-100" y1="20" x2="-50" y2="20" stroke="#1a1a2e" stroke-width="4" opacity="0.3"/>
<line x1="40" y1="-60" x2="100" y2="-60" stroke="#1a1a2e" stroke-width="4" opacity="0.3"/>
<line x1="60" y1="-20" x2="100" y2="-20" stroke="#1a1a2e" stroke-width="4" opacity="0.3"/>
<line x1="50" y1="20" x2="100" y2="20" stroke="#1a1a2e" stroke-width="4" opacity="0.3"/>
</g>
<!-- 墨滴装饰 -->
<circle cx="400" cy="100" r="30" fill="#d4a574" opacity="0.6"/>
<circle cx="420" cy="130" r="15" fill="#d4a574" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

View File

@ -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('<div class="search-hint">输入关键词搜索小说</div>');
}
}, 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('<div class="search-hint">输入关键词搜索小说</div>');
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('<div class="search-hint">输入关键词搜索小说</div>');
// 渲染搜索历史和热门标签
renderSearchTags();
}
async function renderSearchTags() {
let html = '';
// 搜索历史
const history = Store.getSearchHistory();
if (history.length > 0) {
html += '<div class="search-section">';
html += '<div class="search-tags-header"><span class="search-tags-title">搜索历史</span>';
html += '<button class="clear-history-btn" id="clear-search-history">清空</button></div>';
html += '<div class="tags-cloud">';
history.forEach(keyword => {
html += `<button class="tag-btn history-tag" data-keyword="${keyword}">
<span>${keyword}</span>
<svg class="remove-history" data-keyword="${keyword}" viewBox="0 0 24 24" width="14" height="14">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>`;
});
html += '</div></div>';
}
// 热门标签
try {
const tags = await API.getPopularTags(10);
if (tags.length > 0) {
html += '<div class="search-section">';
html += '<div class="search-tags-title">热门标签</div><div class="tags-cloud">';
tags.forEach(tag => {
html += `<button class="tag-btn" data-tag="${tag}">${tag}</button>`;
});
html += '</div></div>';
}
} 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('<div class="chapter-loading"><div class="loading-spinner"></div></div>');
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 += `
<div class="chapter-item" data-novel-id="${novelId}" data-chapter-index="${chapter.index}">
<span class="chapter-index">${chapter.index + 1}</span>
<span class="chapter-title text-ellipsis">${chapter.title}</span>
</div>
`;
});
$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('<p style="padding:20px;text-align:center;">加载失败</p>');
}
// 关闭按钮
$('#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();
});

View File

@ -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(`
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="160" viewBox="0 0 120 160">
<rect fill="#2d2d4a" width="120" height="160"/>
<text fill="#6a6a7a" font-family="serif" font-size="14" x="50%" y="50%" text-anchor="middle" dy=".3em">暂无封面</text>
</svg>
`);
}
// 清理HTML内容中的换行标签
function cleanContent(content) {
if (!content) return '';
return content
.replace(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/<[^>]+>/g, '')
.trim();
}
// ========== 书架页面 ==========
async function renderShelf() {
const $content = $('#app-content');
$content.html(`
<div class="shelf-page page">
<div class="shelf-header">
<h2 class="shelf-title">我的书架</h2>
</div>
<div class="shelf-grid" id="shelf-grid">
${renderSkeletonGrid(6)}
</div>
</div>
`);
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(`
<div class="empty-state" style="grid-column: 1/-1;">
<svg viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/></svg>
<h3>书架空空如也</h3>
<p>去发现页面找本好书吧</p>
</div>
`);
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 `
<div class="shelf-item" data-novel-id="${novel.id}">
<div class="novel-grid-card">
<div class="novel-cover">
<img src="${novel.cover || ''}" alt="${novel.name}" loading="lazy">
</div>
<div class="novel-info">
<div class="novel-title text-ellipsis">${novel.name}</div>
<div class="novel-author text-ellipsis">${novel.author}</div>
</div>
</div>
${progress > 0 ? `
<div class="reading-progress">
<div class="reading-progress-bar" style="width: ${progress}%"></div>
</div>
` : ''}
</div>
`;
}
// ========== 发现页面 ==========
async function renderExplore() {
const $content = $('#app-content');
$content.html(`
<div class="explore-page page">
<div class="explore-banner">
<div class="explore-banner-content">
<h2>发现好书</h2>
<p>探索精彩小说世界</p>
</div>
</div>
<div class="page-section">
<div class="section-header">
<h3 class="section-title">🎲 随机推荐</h3>
<button class="section-more" id="refresh-random">换一批</button>
</div>
<div class="novel-scroll" id="random-novels">
${renderSkeletonScroll(5)}
</div>
</div>
<div class="page-section">
<div class="section-header">
<h3 class="section-title">热门标签</h3>
</div>
<div class="tags-cloud" id="tags-cloud">
<div class="skeleton" style="width:60px;height:32px;border-radius:16px;"></div>
<div class="skeleton" style="width:80px;height:32px;border-radius:16px;"></div>
<div class="skeleton" style="width:50px;height:32px;border-radius:16px;"></div>
</div>
</div>
<div class="page-section">
<div class="section-header">
<h3 class="section-title">全部小说</h3>
</div>
<div class="novel-list" id="novel-list">
${renderSkeletonList(5)}
</div>
<div class="load-more" id="load-more">
<button class="load-more-btn">加载更多</button>
</div>
</div>
</div>
`);
// 加载随机推荐
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('<p style="padding:20px;color:var(--text-muted);">暂无推荐</p>');
return;
}
let html = '';
novels.forEach(novel => {
html += `
<div class="novel-grid-card" data-novel-id="${novel.id}">
<div class="novel-cover">
<img src="${novel.cover || ''}" alt="${novel.name}" loading="lazy">
</div>
<div class="novel-info">
<div class="novel-title text-ellipsis">${novel.name}</div>
<div class="novel-author text-ellipsis">${novel.author}</div>
</div>
</div>
`;
});
$('#random-novels').html(html);
// 绑定图片错误处理
$('#random-novels img').on('error', function() {
handleCoverError(this);
});
} catch (error) {
console.error('加载随机推荐失败:', error);
$('#random-novels').html('<p style="padding:20px;color:var(--text-muted);">加载失败</p>');
}
}
// 渲染横向滚动骨架屏
function renderSkeletonScroll(count) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="novel-grid-card" style="flex-shrink:0;width:100px;">
<div class="novel-cover skeleton" style="width:100px;height:140px;"></div>
<div style="padding:8px;">
<div class="skeleton" style="height:14px;width:80%;margin-bottom:4px;"></div>
<div class="skeleton" style="height:12px;width:50%;"></div>
</div>
</div>
`;
}
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 += `<button class="tag-btn" data-tag="${tag}">${tag}</button>`;
});
$('#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 || '<div class="empty-state"><p>暂无小说</p></div>');
}
// 更新加载更多按钮
if (hasMore) {
$('#load-more').html('<button class="load-more-btn">加载更多</button>');
} else {
$('#load-more').html('<span style="color:var(--text-muted);font-size:14px;">没有更多了</span>');
}
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 `
<div class="novel-card" data-novel-id="${novel.id}">
<div class="novel-cover">
<img src="${novel.cover || ''}" alt="${novel.name}" loading="lazy">
</div>
<div class="novel-info">
<div class="novel-title text-ellipsis">${novel.name}</div>
<div class="novel-author">${novel.author}</div>
<div class="novel-synopsis text-clamp-2">${synopsis}...</div>
<div class="novel-tags">
${tags.map(t => `<span class="tag">${t}</span>`).join('')}
</div>
</div>
</div>
`;
}
// ========== 历史页面 ==========
async function renderHistory() {
const $content = $('#app-content');
$content.html(`
<div class="history-page page">
<div class="history-header">
<h2 class="history-title">阅读历史</h2>
<button class="clear-history-btn" id="clear-history">清空</button>
</div>
<div class="history-list" id="history-list">
${renderSkeletonList(5)}
</div>
</div>
`);
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(`
<div class="empty-state">
<svg viewBox="0 0 24 24"><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>
<h3>暂无阅读记录</h3>
<p>开始阅读后会在这里显示</p>
</div>
`);
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 || '<div class="empty-state"><p>暂无记录</p></div>');
// 绑定图片错误处理
$('#history-list img').on('error', function() {
handleCoverError(this);
});
} catch (error) {
console.error('加载历史失败:', error);
Toast.show('加载失败,请重试');
}
}
function renderHistoryItem(item) {
return `
<div class="history-item" data-novel-id="${item.novelId}" data-chapter-index="${item.chapter?.index || 0}">
<div class="novel-cover">
<img src="${item.novel?.cover || ''}" alt="${item.novel?.name || ''}" loading="lazy">
</div>
<div class="history-info">
<div class="novel-title text-ellipsis">${item.novel?.name || '未知'}</div>
<div class="history-chapter text-ellipsis">${item.chapter?.title || '未知章节'}</div>
<div class="history-time">${formatTime(item.lastReadAt)}</div>
</div>
<div class="item-actions">
<button class="delete-history-btn" data-novel-id="${item.novelId}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</div>
`;
}
// ========== 书签页面 ==========
async function renderBookmarks() {
const $content = $('#app-content');
$content.html(`
<div class="bookmarks-page page">
<div class="bookmarks-header">
<h2 class="bookmarks-title">我的书签</h2>
</div>
<div class="bookmarks-list" id="bookmarks-list">
${renderSkeletonList(5)}
</div>
</div>
`);
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(`
<div class="empty-state">
<svg viewBox="0 0 24 24"><path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"/></svg>
<h3>暂无书签</h3>
<p>阅读时点击书签按钮添加</p>
</div>
`);
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 || '<div class="empty-state"><p>暂无书签</p></div>');
// 绑定图片错误处理
$('#bookmarks-list img').on('error', function() {
handleCoverError(this);
});
} catch (error) {
console.error('加载书签失败:', error);
Toast.show('加载失败,请重试');
}
}
function renderBookmarkItem(item) {
return `
<div class="bookmark-item" data-novel-id="${item.novelId}" data-chapter-index="${item.chapter?.index || 0}">
<div class="novel-cover">
<img src="${item.novel?.cover || ''}" alt="${item.novel?.name || ''}" loading="lazy">
</div>
<div class="bookmark-info">
<div class="novel-title text-ellipsis">${item.novel?.name || '未知'}</div>
<div class="bookmark-chapter text-ellipsis">${item.chapter?.title || '未知章节'}</div>
${item.note ? `<div class="bookmark-note">${item.note}</div>` : ''}
<div class="history-time">${formatTime(item.createdAt)}</div>
</div>
<div class="item-actions">
<button class="delete-bookmark-btn" data-bookmark-id="${item.id}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</div>
`;
}
// ========== 小说详情页面 ==========
async function renderDetail(novelId) {
const $content = $('#app-content');
$content.html(`
<div class="detail-page">
<div class="detail-header">
<div class="detail-cover-wrap">
<div class="novel-cover detail-cover skeleton"></div>
<div class="detail-meta">
<div class="skeleton" style="height:24px;width:80%;"></div>
<div class="skeleton" style="height:18px;width:50%;"></div>
</div>
</div>
</div>
</div>
`);
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 `
<div class="detail-page">
<div class="detail-header">
<div class="detail-cover-wrap">
<div class="novel-cover detail-cover">
<img src="${novel.cover || ''}" alt="${novel.name}">
</div>
<div class="detail-meta">
<h1 class="detail-title">${novel.name}</h1>
<div class="detail-author">${novel.author}</div>
<div class="detail-stats">
<div class="detail-stat">
<div class="detail-stat-value">${novel.chapterCount || 0}</div>
<div class="detail-stat-label">章节</div>
</div>
<div class="detail-stat">
<div class="detail-stat-value">${novel.status === 1 ? '完结' : '连载'}</div>
<div class="detail-stat-label">状态</div>
</div>
</div>
</div>
</div>
</div>
<div class="detail-tags">
${tags.map(t => `<span class="tag">${t}</span>`).join('')}
</div>
<div class="detail-synopsis">
<h3>简介</h3>
<p class="text-clamp-3" id="synopsis-text">${synopsis}</p>
<button class="expand-btn" id="expand-synopsis">展开</button>
</div>
<div class="detail-chapters">
<h3>
目录
<span class="chapter-count">共${novel.chapterCount || 0}</span>
</h3>
<div class="chapters-preview" id="chapters-preview">
<div class="chapter-loading">
<div class="loading-spinner"></div>
<span>加载中...</span>
</div>
</div>
<button class="view-all-chapters" id="view-all-chapters" data-novel-id="${novel.id}">
查看全部章节
<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
<div class="detail-actions">
<button class="btn-shelf ${inShelf ? 'in-shelf' : ''}" id="btn-shelf" data-novel-id="${novel.id}">
<svg viewBox="0 0 24 24"><path d="${inShelf ? 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' : 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z'}"/></svg>
${inShelf ? '已在书架' : '加入书架'}
</button>
<button class="btn-read" id="btn-read" data-novel-id="${novel.id}" data-chapter-index="${position?.chapterIndex || 0}">
${position ? '继续阅读' : '开始阅读'}
</button>
</div>
</div>
`;
}
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 += `
<div class="chapter-item" data-novel-id="${novelId}" data-chapter-index="${chapter.index}">
<span class="chapter-index">${chapter.index + 1}</span>
<span class="chapter-title text-ellipsis">${chapter.title}</span>
</div>
`;
});
$('#chapters-preview').html(html || '<p style="padding:16px;color:var(--text-muted);">暂无章节</p>');
} catch (error) {
console.error('加载章节失败:', error);
}
}
// ========== 搜索功能 ==========
async function renderSearchResults(keyword) {
const $results = $('#search-results');
$results.html('<div class="chapter-loading"><div class="loading-spinner"></div><span>搜索中...</span></div>');
try {
const data = await API.searchNovels({ keyword, page: 0, size: 30 });
const novels = data.content || [];
if (novels.length === 0) {
$results.html('<div class="search-hint">未找到相关小说</div>');
return;
}
let html = '<div class="novel-list">';
novels.forEach(novel => {
html += renderNovelCard(novel);
});
html += '</div>';
$results.html(html);
// 绑定图片错误处理
$results.find('img').on('error', function() {
handleCoverError(this);
});
} catch (error) {
console.error('搜索失败:', error);
$results.html('<div class="search-hint">搜索失败,请重试</div>');
}
}
async function renderSearchByTag(tag) {
const $results = $('#search-results');
$results.html('<div class="chapter-loading"><div class="loading-spinner"></div><span>加载中...</span></div>');
try {
const data = await API.searchNovels({ tag, page: 0, size: 30 });
const novels = data.content || [];
if (novels.length === 0) {
$results.html('<div class="search-hint">该标签下暂无小说</div>');
return;
}
let html = '<div class="novel-list">';
novels.forEach(novel => {
html += renderNovelCard(novel);
});
html += '</div>';
$results.html(html);
// 绑定图片错误处理
$results.find('img').on('error', function() {
handleCoverError(this);
});
} catch (error) {
console.error('加载失败:', error);
$results.html('<div class="search-hint">加载失败,请重试</div>');
}
}
// ========== 骨架屏 ==========
function renderSkeletonGrid(count) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="shelf-item">
<div class="novel-grid-card">
<div class="novel-cover skeleton"></div>
<div class="novel-info">
<div class="skeleton" style="height:14px;width:80%;margin-bottom:4px;"></div>
<div class="skeleton" style="height:12px;width:50%;"></div>
</div>
</div>
</div>
`;
}
return html;
}
function renderSkeletonList(count) {
let html = '';
for (let i = 0; i < count; i++) {
html += `
<div class="skeleton-card">
<div class="skeleton skeleton-cover"></div>
<div class="skeleton-info">
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-desc"></div>
</div>
</div>
`;
}
return html;
}
// 公开API
return {
renderShelf,
renderExplore,
renderHistory,
renderBookmarks,
renderDetail,
renderSearchResults,
renderSearchByTag,
loadNovels,
currentPage: () => currentPage,
hasMore: () => hasMore
};
})();

View File

@ -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(`
<div class="reader-page">
<div class="reader-content">
<div class="chapter-loading">
<div class="loading-spinner"></div>
<span>加载中...</span>
</div>
</div>
</div>
`);
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(`
<div class="reader-page">
<div class="reader-content">
<div class="chapter-error">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
<p>加载失败</p>
<button onclick="Reader.reload()">重试</button>
</div>
</div>
</div>
`);
}
}
function renderChapter(chapter) {
// 处理内容格式
let content = chapter.content || '';
content = content
.replace(/<br\s*\/?>/gi, '</p><p>')
.replace(/&nbsp;/g, ' ');
// 如果没有段落标签,按换行分段
if (!content.includes('<p>')) {
content = '<p>' + content.split('\n').filter(p => p.trim()).join('</p><p>') + '</p>';
}
const $content = $('#app-content');
$content.html(`
<div class="reader-page">
<div class="reader-content">
<h1 class="reader-chapter-title">${chapter.title}</h1>
<div class="reader-text">${content}</div>
<div class="reader-nav">
<button id="nav-prev" ${currentChapterIndex <= 0 ? 'disabled' : ''}>
<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
上一章
</button>
<button id="nav-next">
下一章
<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</div>
</div>
`);
// 应用设置
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('<div class="chapter-loading"><div class="loading-spinner"></div></div>');
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('<p style="padding:20px;text-align:center;color:var(--text-muted);">加载失败</p>');
}
}
function renderCatalogList(chapters) {
let html = '';
chapters.forEach(chapter => {
const isCurrent = chapter.index === currentChapterIndex;
html += `
<div class="chapter-item ${isCurrent ? 'current' : ''}"
data-chapter-index="${chapter.index}">
<span class="chapter-index">${chapter.index + 1}</span>
<span class="chapter-title text-ellipsis">${chapter.title}</span>
</div>
`;
});
$('#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('<div class="reading-progress-indicator"><div class="progress-bar"></div></div>');
}
}
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
};
})();

View File

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

View File

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

View File

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

View File

@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#1a1a2e">
<title>墨香阁 - 小说阅读</title>
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="apple-touch-icon" href="/icon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&family=Noto+Serif+SC:wght@400;600;700&display=swap" rel="stylesheet">
<style>
/* 关键内联样式 - 防止闪烁 */
:root {
--primary: #d4a574;
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #232341;
--text-primary: #eaeaea;
--text-muted: #6a6a7a;
--border-color: #3a3a5a;
--spacing-md: 16px;
--spacing-sm: 8px;
--header-height: 56px;
--nav-height: 60px;
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Serif SC', 'STSong', serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
svg { display: inline-block; width: 24px; height: 24px; vertical-align: middle; }
#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);
display: flex; align-items: center; justify-content: space-between;
padding-left: var(--spacing-md); padding-right: var(--spacing-md);
z-index: 100; border-bottom: 1px solid var(--border-color);
}
.header-left, .header-right { display: flex; align-items: center; min-width: 80px; }
.header-right { justify-content: flex-end; }
.header-title { flex: 1; text-align: center; font-size: 22px; color: var(--primary); }
.header-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: none; border: none; cursor: pointer; }
.header-btn svg { width: 24px; 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);
display: flex; align-items: stretch; justify-content: space-around;
z-index: 100; border-top: 1px solid var(--border-color);
}
.nav-item {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; color: var(--text-muted); text-decoration: none;
padding-top: var(--spacing-sm);
}
.nav-item svg { width: 24px; height: 24px; fill: currentColor; }
.nav-item span { font-size: 12px; }
.nav-item.active { color: var(--primary); }
#app-content {
min-height: 100vh;
padding-top: calc(var(--header-height) + var(--safe-area-top));
padding-bottom: calc(var(--nav-height) + var(--safe-area-bottom));
padding-left: var(--spacing-md); padding-right: var(--spacing-md);
}
.hidden { display: none !important; }
#app-loader {
position: fixed; inset: 0; background: var(--bg-primary);
display: flex; align-items: center; justify-content: center; z-index: 9999;
}
.loader-text { font-size: 28px; color: var(--primary); }
.novel-scroll {
display: flex; gap: 16px; overflow-x: auto; -webkit-overflow-scrolling: touch;
padding: 0 16px 8px; margin: 0 -16px;
}
.novel-scroll::-webkit-scrollbar { display: none; }
.novel-scroll .novel-grid-card { flex-shrink: 0; width: 100px; }
.section-more { font-size: 14px; color: var(--primary); background: none; border: none; cursor: pointer; }
.search-tags-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.history-tag { display: inline-flex; align-items: center; gap: 4px; }
</style>
<link rel="stylesheet" href="/css/base.css">
<link rel="stylesheet" href="/css/components.css">
<link rel="stylesheet" href="/css/pages.css">
<link rel="stylesheet" href="/css/reader.css">
</head>
<body>
<!-- 加载动画 -->
<div id="app-loader">
<div class="loader-content">
<div class="ink-drop"></div>
<span class="loader-text">墨香阁</span>
</div>
</div>
<!-- 主容器 -->
<div id="app">
<!-- 顶部导航 -->
<header id="app-header">
<div class="header-left">
<button id="btn-back" class="header-btn hidden">
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
</div>
<h1 id="page-title" class="header-title">墨香阁</h1>
<div class="header-right">
<button id="btn-search" class="header-btn">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
</button>
<button id="btn-menu" class="header-btn">
<svg viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
</button>
</div>
</header>
<!-- 内容区域 -->
<main id="app-content">
<!-- 动态内容由JS渲染 -->
</main>
<!-- 底部导航 -->
<nav id="app-nav">
<a href="#/shelf" class="nav-item active" data-page="shelf">
<svg viewBox="0 0 24 24"><path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/></svg>
<span>书架</span>
</a>
<a href="#/explore" class="nav-item" data-page="explore">
<svg viewBox="0 0 24 24"><path d="M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z"/></svg>
<span>发现</span>
</a>
<a href="#/history" class="nav-item" data-page="history">
<svg viewBox="0 0 24 24"><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>
<span>历史</span>
</a>
<a href="#/bookmarks" class="nav-item" data-page="bookmarks">
<svg viewBox="0 0 24 24"><path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"/></svg>
<span>书签</span>
</a>
</nav>
<!-- 阅读页面工具栏(隐藏状态) -->
<div id="reader-toolbar" class="hidden">
<div class="toolbar-top">
<button id="reader-back" class="toolbar-btn">
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<span id="reader-title"></span>
<button id="reader-bookmark" class="toolbar-btn">
<svg viewBox="0 0 24 24"><path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2zm0 15l-5-2.18L7 18V5h10v13z"/></svg>
</button>
</div>
<div class="toolbar-bottom">
<button id="reader-prev" class="toolbar-btn">
<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
<span>上一章</span>
</button>
<button id="reader-catalog" class="toolbar-btn">
<svg viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>
<span>目录</span>
</button>
<button id="reader-settings" class="toolbar-btn">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span>设置</span>
</button>
<button id="reader-next" class="toolbar-btn">
<span>下一章</span>
<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
</div>
<!-- 阅读设置面板 -->
<div id="reader-settings-panel" class="hidden">
<div class="settings-overlay"></div>
<div class="settings-content">
<div class="settings-header">
<span>阅读设置</span>
<button id="close-settings">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div class="settings-group">
<label>字体大小</label>
<div class="font-size-control">
<button id="font-decrease">A-</button>
<span id="font-size-value">18</span>
<button id="font-increase">A+</button>
</div>
</div>
<div class="settings-group">
<label>行间距</label>
<div class="line-height-control">
<button class="line-height-btn" data-value="1.5">紧凑</button>
<button class="line-height-btn active" data-value="1.8">适中</button>
<button class="line-height-btn" data-value="2.2">宽松</button>
</div>
</div>
<div class="settings-group">
<label>主题</label>
<div class="theme-control">
<button class="theme-btn" data-theme="light" title="浅色">
<span class="theme-preview light"></span>
</button>
<button class="theme-btn" data-theme="sepia" title="护眼">
<span class="theme-preview sepia"></span>
</button>
<button class="theme-btn active" data-theme="dark" title="深色">
<span class="theme-preview dark"></span>
</button>
<button class="theme-btn" data-theme="amoled" title="纯黑">
<span class="theme-preview amoled"></span>
</button>
</div>
</div>
</div>
</div>
<!-- 目录面板 -->
<div id="catalog-panel" class="hidden">
<div class="catalog-overlay"></div>
<div class="catalog-content">
<div class="catalog-header">
<span>目录</span>
<button id="close-catalog">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div class="catalog-search">
<input type="text" id="catalog-search-input" placeholder="搜索章节...">
</div>
<div id="catalog-list" class="catalog-list">
<!-- 章节列表 -->
</div>
</div>
</div>
<!-- 搜索面板 -->
<div id="search-panel" class="hidden">
<div class="search-header">
<button id="search-back" class="header-btn">
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<div class="search-input-wrap">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="search-input" placeholder="搜索书名、作者...">
<button id="search-clear" class="hidden">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
</div>
<div id="search-tags" class="search-tags">
<!-- 热门标签 -->
</div>
<div id="search-results" class="search-results">
<!-- 搜索结果 -->
</div>
</div>
<!-- Toast提示 -->
<div id="toast" class="hidden"></div>
</div>
<!-- 书签添加弹窗 -->
<div id="bookmark-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-content">
<div class="modal-header">
<span>添加书签</span>
<button class="modal-close">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div class="modal-body">
<textarea id="bookmark-note" placeholder="添加备注(可选)..."></textarea>
</div>
<div class="modal-footer">
<button class="btn-cancel">取消</button>
<button class="btn-confirm">确定</button>
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/store.js"></script>
<script src="/js/pages.js"></script>
<script src="/js/reader.js"></script>
<script src="/js/app.js"></script>
</body>
</html>