init
This commit is contained in:
commit
a20550422f
28
.dockerignore
Normal file
28
.dockerignore
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.idea/
|
||||
target/
|
||||
|
||||
*.tar
|
||||
158
DEPLOY.md
Normal file
158
DEPLOY.md
Normal 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
33
Dockerfile
Normal 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
50
Dockerfile.china
Normal 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
21
Dockerfile.simple
Normal 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
39
docker-compose.yml
Normal 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
92
pom.xml
Normal 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>
|
||||
|
||||
12
src/main/java/com/novelreader/NovelReaderApplication.java
Normal file
12
src/main/java/com/novelreader/NovelReaderApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
80
src/main/java/com/novelreader/config/MongoIndexConfig.java
Normal file
80
src/main/java/com/novelreader/config/MongoIndexConfig.java
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
44
src/main/java/com/novelreader/controller/PageController.java
Normal file
44
src/main/java/com/novelreader/controller/PageController.java
Normal 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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
17
src/main/java/com/novelreader/dto/BookmarkRequest.java
Normal file
17
src/main/java/com/novelreader/dto/BookmarkRequest.java
Normal 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;
|
||||
}
|
||||
|
||||
19
src/main/java/com/novelreader/dto/ChapterContent.java
Normal file
19
src/main/java/com/novelreader/dto/ChapterContent.java
Normal 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;
|
||||
}
|
||||
|
||||
18
src/main/java/com/novelreader/dto/ChapterHead.java
Normal file
18
src/main/java/com/novelreader/dto/ChapterHead.java
Normal 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;
|
||||
}
|
||||
|
||||
15
src/main/java/com/novelreader/dto/HistoryRequest.java
Normal file
15
src/main/java/com/novelreader/dto/HistoryRequest.java
Normal 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;
|
||||
}
|
||||
|
||||
23
src/main/java/com/novelreader/dto/NovelBrief.java
Normal file
23
src/main/java/com/novelreader/dto/NovelBrief.java
Normal 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;
|
||||
}
|
||||
|
||||
26
src/main/java/com/novelreader/dto/NovelDetail.java
Normal file
26
src/main/java/com/novelreader/dto/NovelDetail.java
Normal 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;
|
||||
}
|
||||
|
||||
15
src/main/java/com/novelreader/dto/ShelfRequest.java
Normal file
15
src/main/java/com/novelreader/dto/ShelfRequest.java
Normal 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;
|
||||
}
|
||||
|
||||
21
src/main/java/com/novelreader/model/Bookmark.java
Normal file
21
src/main/java/com/novelreader/model/Bookmark.java
Normal 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;
|
||||
}
|
||||
|
||||
20
src/main/java/com/novelreader/model/ChapterDO.java
Normal file
20
src/main/java/com/novelreader/model/ChapterDO.java
Normal 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;
|
||||
}
|
||||
|
||||
20
src/main/java/com/novelreader/model/History.java
Normal file
20
src/main/java/com/novelreader/model/History.java
Normal 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;
|
||||
}
|
||||
|
||||
30
src/main/java/com/novelreader/model/NovelDO.java
Normal file
30
src/main/java/com/novelreader/model/NovelDO.java
Normal 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;
|
||||
}
|
||||
|
||||
20
src/main/java/com/novelreader/model/ShelfItem.java
Normal file
20
src/main/java/com/novelreader/model/ShelfItem.java
Normal 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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
57
src/main/java/com/novelreader/service/BookmarkService.java
Normal file
57
src/main/java/com/novelreader/service/BookmarkService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
50
src/main/java/com/novelreader/service/ChapterService.java
Normal file
50
src/main/java/com/novelreader/service/ChapterService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
52
src/main/java/com/novelreader/service/HistoryService.java
Normal file
52
src/main/java/com/novelreader/service/HistoryService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
150
src/main/java/com/novelreader/service/NovelService.java
Normal file
150
src/main/java/com/novelreader/service/NovelService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
59
src/main/java/com/novelreader/service/ShelfService.java
Normal file
59
src/main/java/com/novelreader/service/ShelfService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
14
src/main/resources/application.yml
Normal file
14
src/main/resources/application.yml
Normal 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
|
||||
|
||||
9
src/main/resources/static/css/app.css
Normal file
9
src/main/resources/static/css/app.css
Normal file
@ -0,0 +1,9 @@
|
||||
/* ============================================
|
||||
主样式入口 - 导入所有CSS模块
|
||||
============================================ */
|
||||
|
||||
@import url('base.css');
|
||||
@import url('components.css');
|
||||
@import url('pages.css');
|
||||
@import url('reader.css');
|
||||
|
||||
337
src/main/resources/static/css/base.css
Normal file
337
src/main/resources/static/css/base.css
Normal 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;
|
||||
}
|
||||
|
||||
615
src/main/resources/static/css/components.css
Normal file
615
src/main/resources/static/css/components.css
Normal 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%;
|
||||
}
|
||||
|
||||
617
src/main/resources/static/css/pages.css
Normal file
617
src/main/resources/static/css/pages.css
Normal 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);
|
||||
}
|
||||
|
||||
483
src/main/resources/static/css/reader.css
Normal file
483
src/main/resources/static/css/reader.css
Normal 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);
|
||||
}
|
||||
|
||||
36
src/main/resources/static/icon.svg
Normal file
36
src/main/resources/static/icon.svg
Normal 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 |
289
src/main/resources/static/js/api.js
Normal file
289
src/main/resources/static/js/api.js
Normal 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
|
||||
};
|
||||
})();
|
||||
|
||||
543
src/main/resources/static/js/app.js
Normal file
543
src/main/resources/static/js/app.js
Normal 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();
|
||||
});
|
||||
|
||||
753
src/main/resources/static/js/pages.js
Normal file
753
src/main/resources/static/js/pages.js
Normal 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(/ /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
|
||||
};
|
||||
})();
|
||||
|
||||
538
src/main/resources/static/js/reader.js
Normal file
538
src/main/resources/static/js/reader.js
Normal 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(/ /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
|
||||
};
|
||||
})();
|
||||
|
||||
240
src/main/resources/static/js/store.js
Normal file
240
src/main/resources/static/js/store.js
Normal 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();
|
||||
|
||||
24
src/main/resources/static/manifest.json
Normal file
24
src/main/resources/static/manifest.json
Normal 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
|
||||
}
|
||||
|
||||
199
src/main/resources/static/sw.js
Normal file
199
src/main/resources/static/sw.js
Normal 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('/')
|
||||
);
|
||||
});
|
||||
|
||||
302
src/main/resources/templates/index.html
Normal file
302
src/main/resources/templates/index.html
Normal 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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user