feat(fullstack): 接入 MySQL 8.0 持久化与 Spring Boot 3 分层企业级架构,支持文章与评论完整 CRUD
This commit is contained in:
@@ -19,6 +19,19 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|||||||
@@ -2,231 +2,10 @@ package com.example.blog;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@RestController
|
|
||||||
@CrossOrigin(origins = "*")
|
|
||||||
@RequestMapping("/api")
|
|
||||||
public class BlogApplication {
|
public class BlogApplication {
|
||||||
|
|
||||||
public static class Post {
|
|
||||||
private String id;
|
|
||||||
private String title;
|
|
||||||
private String summary;
|
|
||||||
private String content;
|
|
||||||
private String author;
|
|
||||||
private String date;
|
|
||||||
private String category;
|
|
||||||
private List<String> tags;
|
|
||||||
private String readTime;
|
|
||||||
private int views;
|
|
||||||
private int likes;
|
|
||||||
|
|
||||||
public Post() {}
|
|
||||||
|
|
||||||
public Post(String id, String title, String summary, String content, String author,
|
|
||||||
String date, String category, List<String> tags, String readTime, int views, int likes) {
|
|
||||||
this.id = id;
|
|
||||||
this.title = title;
|
|
||||||
this.summary = summary;
|
|
||||||
this.content = content;
|
|
||||||
this.author = author;
|
|
||||||
this.date = date;
|
|
||||||
this.category = category;
|
|
||||||
this.tags = tags;
|
|
||||||
this.readTime = readTime;
|
|
||||||
this.views = views;
|
|
||||||
this.likes = likes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() { return id; }
|
|
||||||
public void setId(String id) { this.id = id; }
|
|
||||||
public String getTitle() { return title; }
|
|
||||||
public void setTitle(String title) { this.title = title; }
|
|
||||||
public String getSummary() { return summary; }
|
|
||||||
public void setSummary(String summary) { this.summary = summary; }
|
|
||||||
public String getContent() { return content; }
|
|
||||||
public void setContent(String content) { this.content = content; }
|
|
||||||
public String getAuthor() { return author; }
|
|
||||||
public void setAuthor(String author) { this.author = author; }
|
|
||||||
public String getDate() { return date; }
|
|
||||||
public void setDate(String date) { this.date = date; }
|
|
||||||
public String getCategory() { return category; }
|
|
||||||
public void setCategory(String category) { this.category = category; }
|
|
||||||
public List<String> getTags() { return tags; }
|
|
||||||
public void setTags(List<String> tags) { this.tags = tags; }
|
|
||||||
public String getReadTime() { return readTime; }
|
|
||||||
public void setReadTime(String readTime) { this.readTime = readTime; }
|
|
||||||
public int getViews() { return views; }
|
|
||||||
public void setViews(int views) { this.views = views; }
|
|
||||||
public int getLikes() { return likes; }
|
|
||||||
public void setLikes(int likes) { this.likes = likes; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final List<Post> POSTS = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
static {
|
|
||||||
POSTS.add(new Post(
|
|
||||||
"1",
|
|
||||||
"云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel",
|
|
||||||
"本文记录了如何通过 GCP 赠金在亚洲低延迟机房部署 4核16G 高性能云服务器,并使用现代化容器面板 1Panel 进行运维管理实录。",
|
|
||||||
"# 云原生实践:在 Google Cloud 台湾节点搭建高性能服务器\n\n" +
|
|
||||||
"在挑选云服务器的过程中,综合对比了各大厂商的免费策略与性能表现,最终选择了 **Google Cloud 台湾节点 (`asia-east1`)**。\n\n" +
|
|
||||||
"## 核心硬件与网络配置\n\n" +
|
|
||||||
"- **规格**:`e2-standard-4`(4 vCPU,16 GB 内存)\n" +
|
|
||||||
"- **系统**:Ubuntu 22.04 LTS (x86_64)\n" +
|
|
||||||
"- **延迟**:到中国大陆仅 30~50ms,操作极为丝滑。\n\n" +
|
|
||||||
"## 为什么采用 1Panel 面板?\n\n" +
|
|
||||||
"相比传统臃肿的面料,1Panel 深度拥抱 **Docker 容器化**,所有应用(MySQL、OpenResty、Gitea)完全容器隔离,真正做到“环境随用随建,数据挂载持久化”。\n\n" +
|
|
||||||
"```bash\n# 快速检查 Docker 运行状态\ndocker ps --format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\"\n```\n\n" +
|
|
||||||
"> 💡 **运维心得**:永远在云平台控制台统一放行防火墙端口,切勿盲目在系统内部启用 UFW,避免失联风险。",
|
|
||||||
"旺总",
|
|
||||||
"2026-10-22",
|
|
||||||
"云原生 & Linux",
|
|
||||||
Arrays.asList("GCP", "Ubuntu", "1Panel", "Linux"),
|
|
||||||
"4 min",
|
|
||||||
528,
|
|
||||||
42
|
|
||||||
));
|
|
||||||
|
|
||||||
POSTS.add(new Post(
|
|
||||||
"2",
|
|
||||||
"探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优",
|
|
||||||
"深度解析在纯 CPU 服务器上运行 DeepSeek R1 和通义千问 2.5 的真实表现,揭开 2050 Token 预填充延迟背后的机制。",
|
|
||||||
"# 探索本地大模型:从 Ollama 到 Open WebUI 性能调优实战\n\n" +
|
|
||||||
"近期 DeepSeek R1 推理思考模型火遍全球,我们在 4核16G 的服务器上进行了本地大模型的全链路压测。\n\n" +
|
|
||||||
"## 1. 架构搭建\n\n" +
|
|
||||||
"- **模型发动机**:`Ollama` 容器\n" +
|
|
||||||
"- **交互界面**:`Open WebUI` 容器\n" +
|
|
||||||
"- **网络通信**:通过 Docker 网关 `http://172.17.0.1:11434` 极速直连\n\n" +
|
|
||||||
"## 2. 性能排查与调优秘籍\n\n" +
|
|
||||||
"测试过程中遇到了“每次提问卡顿 50 秒”的问题,经过 `top` 分析和指标剖析:\n\n" +
|
|
||||||
"1. **解除容器 CPU 配额**:确保 4 线程全开(`eval rate` 提升至 14 tokens/s);\n" +
|
|
||||||
"2. **内存永久常驻**:注入 `OLLAMA_KEEP_ALIVE=-1`,避免 5 分钟超时后重新读盘;\n" +
|
|
||||||
"3. **清空全局默认 Prompt**:消除了默认附带的 2050 历史 Token,读题耗时从 60s 骤降至 0.2s!\n\n" +
|
|
||||||
"```bash\n# 测试模型运行性能\ndocker exec -it 1Panel-ollama-nzre ollama run qwen2.5:1.5b --verbose\n```\n\n" +
|
|
||||||
"> 纯 CPU 跑 7B 参数是物理带宽瓶颈,日常开发推荐搭配 1.5B~3B 模型,速度与逻辑达到最佳平衡!",
|
|
||||||
"旺总",
|
|
||||||
"2026-10-22",
|
|
||||||
"AI & 本地模型",
|
|
||||||
Arrays.asList("DeepSeek", "Ollama", "OpenWebUI", "性能调优"),
|
|
||||||
"6 min",
|
|
||||||
892,
|
|
||||||
68
|
|
||||||
));
|
|
||||||
|
|
||||||
POSTS.add(new Post(
|
|
||||||
"3",
|
|
||||||
"全栈工程师实战:Spring Boot 3 + Vue 3 博客与 Gitea CI/CD 自动化流水线",
|
|
||||||
"从零手写前后端分离架构,配合私有 Gitea 仓库与自动化部署脚本,实现 push 代码后全自动打包上线。",
|
|
||||||
"# 全栈工程化实践:Spring Boot 3 + Vue 3 + CI/CD 自动化\n\n" +
|
|
||||||
"现代化开发的精髓在于**“把重复劳动交给自动化”**。我们搭建了一套小而美但功能极其完备的企业级全栈流水线。\n\n" +
|
|
||||||
"## 全栈架构一览\n\n" +
|
|
||||||
"```text\n[开发者本地 IDEA] ──git push──▶ [私有 Gitea 仓库]\n │\n (Webhook 触发流水线)\n ▼\n [deploy.sh 自动化脚本]\n │\n ┌─────────────┴─────────────┐\n ▼ ▼\n [Spring Boot 3 容器] [Nginx 静态反代容器]\n```\n\n" +
|
|
||||||
"## Nginx 反向代理配置关键点\n\n" +
|
|
||||||
"前端容器中通过反代 `/api/` 到后端服务容器,天然解决跨域和外部端口分散问题:\n\n" +
|
|
||||||
"```nginx\nlocation /api/ {\n proxy_pass http://backend:8080/api/;\n proxy_set_header Host $host;\n}\n```\n\n" +
|
|
||||||
"从此,改完代码只需一条 `git push`,剩下的事情交给服务器自动化即可!",
|
|
||||||
"旺总",
|
|
||||||
"2026-10-22",
|
|
||||||
"全栈开发 & CI/CD",
|
|
||||||
Arrays.asList("Spring Boot", "Vue 3", "CI/CD", "Docker Compose"),
|
|
||||||
"5 min",
|
|
||||||
641,
|
|
||||||
55
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/posts")
|
|
||||||
public List<Post> getPosts(
|
|
||||||
@RequestParam(required = false) String category,
|
|
||||||
@RequestParam(required = false) String search) {
|
|
||||||
return POSTS.stream()
|
|
||||||
.filter(p -> category == null || category.isEmpty() || category.equalsIgnoreCase("all") || p.getCategory().equalsIgnoreCase(category))
|
|
||||||
.filter(p -> search == null || search.isEmpty() ||
|
|
||||||
p.getTitle().toLowerCase().contains(search.toLowerCase()) ||
|
|
||||||
p.getSummary().toLowerCase().contains(search.toLowerCase()))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/posts/{id}")
|
|
||||||
public Post getPost(@PathVariable String id) {
|
|
||||||
return POSTS.stream()
|
|
||||||
.filter(p -> p.getId().equals(id))
|
|
||||||
.findFirst()
|
|
||||||
.map(p -> {
|
|
||||||
p.setViews(p.getViews() + 1);
|
|
||||||
return p;
|
|
||||||
})
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/posts")
|
|
||||||
public Post addPost(@RequestBody Post post) {
|
|
||||||
String newId = String.valueOf(POSTS.size() + 1);
|
|
||||||
post.setId(newId);
|
|
||||||
if (post.getDate() == null || post.getDate().isEmpty()) {
|
|
||||||
post.setDate(LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
|
||||||
}
|
|
||||||
if (post.getAuthor() == null || post.getAuthor().isEmpty()) {
|
|
||||||
post.setAuthor("旺总");
|
|
||||||
}
|
|
||||||
if (post.getCategory() == null || post.getCategory().isEmpty()) {
|
|
||||||
post.setCategory("技术杂谈");
|
|
||||||
}
|
|
||||||
if (post.getReadTime() == null || post.getReadTime().isEmpty()) {
|
|
||||||
post.setReadTime("3 min");
|
|
||||||
}
|
|
||||||
if (post.getTags() == null || post.getTags().isEmpty()) {
|
|
||||||
post.setTags(Collections.singletonList(post.getCategory()));
|
|
||||||
}
|
|
||||||
post.setViews(1);
|
|
||||||
post.setLikes(0);
|
|
||||||
POSTS.add(0, post);
|
|
||||||
return post;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/posts/{id}/like")
|
|
||||||
public Map<String, Object> likePost(@PathVariable String id) {
|
|
||||||
Map<String, Object> res = new HashMap<>();
|
|
||||||
for (Post p : POSTS) {
|
|
||||||
if (p.getId().equals(id)) {
|
|
||||||
p.setLikes(p.getLikes() + 1);
|
|
||||||
res.put("success", true);
|
|
||||||
res.put("likes", p.getLikes());
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.put("success", false);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/posts/{id}")
|
|
||||||
public Map<String, Object> deletePost(@PathVariable String id) {
|
|
||||||
boolean removed = POSTS.removeIf(p -> p.getId().equals(id));
|
|
||||||
Map<String, Object> res = new HashMap<>();
|
|
||||||
res.put("success", removed);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/stats")
|
|
||||||
public Map<String, Object> getStats() {
|
|
||||||
Map<String, Object> stats = new HashMap<>();
|
|
||||||
stats.put("totalPosts", POSTS.size());
|
|
||||||
stats.put("totalViews", POSTS.stream().mapToInt(Post::getViews).sum());
|
|
||||||
stats.put("totalLikes", POSTS.stream().mapToInt(Post::getLikes).sum());
|
|
||||||
Set<String> categories = POSTS.stream().map(Post::getCategory).collect(Collectors.toSet());
|
|
||||||
stats.put("categoriesCount", categories.size());
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(BlogApplication.class, args);
|
SpringApplication.run(BlogApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.blog.common;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
public Result<Void> handleException(Exception e) {
|
||||||
|
return Result.error(e.getMessage() != null ? e.getMessage() : "服务器内部错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.example.blog.common;
|
||||||
|
|
||||||
|
public class Result<T> {
|
||||||
|
private int code;
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
public Result() {}
|
||||||
|
|
||||||
|
public Result(int code, String message, T data) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> success(T data) {
|
||||||
|
return new Result<>(200, "操作成功", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> success() {
|
||||||
|
return new Result<>(200, "操作成功", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> error(String message) {
|
||||||
|
return new Result<>(500, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Result<T> error(int code, String message) {
|
||||||
|
return new Result<>(code, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() { return code; }
|
||||||
|
public void setCode(int code) { this.code = code; }
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public void setMessage(String message) { this.message = message; }
|
||||||
|
public T getData() { return data; }
|
||||||
|
public void setData(T data) { this.data = data; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.example.blog.config;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Category;
|
||||||
|
import com.example.blog.entity.Post;
|
||||||
|
import com.example.blog.repository.CategoryRepository;
|
||||||
|
import com.example.blog.repository.PostRepository;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
|
private final PostRepository postRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
|
||||||
|
public DataInitializer(PostRepository postRepository, CategoryRepository categoryRepository) {
|
||||||
|
this.postRepository = postRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
// 1. 初始化分类
|
||||||
|
if (categoryRepository.count() == 0) {
|
||||||
|
List<Category> categories = Arrays.asList(
|
||||||
|
new Category("云原生 & Linux", "容器化编排、GCP/云服务器运维、系统调优与网络配置实战"),
|
||||||
|
new Category("Java & Spring Boot", "Spring Boot 3 微服务架构、JPA 数据库持久化与后端工程规范"),
|
||||||
|
new Category("AI & 本地模型", "Ollama 本地大模型部署、DeepSeek R1 性能压测与应用集成"),
|
||||||
|
new Category("全栈开发 & CI/CD", "前后端分离架构、Gitea Actions 自动化流水线交付")
|
||||||
|
);
|
||||||
|
categoryRepository.saveAll(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化预置文章
|
||||||
|
if (postRepository.count() == 0) {
|
||||||
|
Post p1 = new Post();
|
||||||
|
p1.setTitle("云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel");
|
||||||
|
p1.setSummary("本文记录了如何通过 GCP 赠金在亚洲低延迟机房部署 4核16G 高性能云服务器,并使用现代化容器面板 1Panel 进行运维管理实录。");
|
||||||
|
p1.setContent("# 云原生实践:在 Google Cloud 台湾节点搭建高性能服务器\n\n" +
|
||||||
|
"在挑选云服务器的过程中,综合对比了各大厂商的免费策略与性能表现,最终选择了 **Google Cloud 台湾节点 (`asia-east1`)**。\n\n" +
|
||||||
|
"## 核心硬件与网络配置\n" +
|
||||||
|
"- **规格**:`e2-standard-4`(4 vCPU,16 GB 内存)\n" +
|
||||||
|
"- **系统**:Ubuntu 22.04 LTS (x86_64)\n" +
|
||||||
|
"- **延迟**:到中国大陆仅 30~50ms,操作极为丝滑。\n\n" +
|
||||||
|
"## 为什么采用 1Panel 面板?\n" +
|
||||||
|
"相比传统臃肿的面料,1Panel 深度拥抱 **Docker 容器化**,所有应用(MySQL、OpenResty、Gitea)完全容器隔离,真正做到“环境随用随建,数据挂载持久化”。\n\n" +
|
||||||
|
"```bash\n# 快速检查 Docker 运行状态\ndocker ps --format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\"\n```\n\n" +
|
||||||
|
"> 💡 **运维心得**:永远在云平台控制台统一放行防火墙端口,切勿盲目在系统内部启用 UFW,避免失联风险。");
|
||||||
|
p1.setAuthor("旺总");
|
||||||
|
p1.setCategoryName("云原生 & Linux");
|
||||||
|
p1.setTags("GCP,Ubuntu,1Panel,Linux");
|
||||||
|
p1.setReadTime("4 min");
|
||||||
|
p1.setViews(682);
|
||||||
|
p1.setLikes(58);
|
||||||
|
|
||||||
|
Post p2 = new Post();
|
||||||
|
p2.setTitle("探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优");
|
||||||
|
p2.setSummary("深度解析在纯 CPU 服务器上运行 DeepSeek R1 和通义千问 2.5 的真实表现,揭开 2050 Token 预填充延迟背后的机制。");
|
||||||
|
p2.setContent("# 探索本地大模型:从 Ollama 到 Open WebUI 性能调优实战\n\n" +
|
||||||
|
"近期 DeepSeek R1 推理思考模型火遍全球,我们在 4核16G 的服务器上进行了本地大模型的全链路压测。\n\n" +
|
||||||
|
"## 1. 架构搭建\n" +
|
||||||
|
"- **模型发动机**:`Ollama` 容器\n" +
|
||||||
|
"- **交互界面**:`Open WebUI` 容器\n" +
|
||||||
|
"- **网络通信**:通过 Docker 网关 `http://172.17.0.1:11434` 极速直连\n\n" +
|
||||||
|
"## 2. 性能排查与调优秘籍\n" +
|
||||||
|
"测试过程中遇到了“每次提问卡顿 50 秒”的问题,经过 `top` 分析和指标剖析:\n" +
|
||||||
|
"1. **解除容器 CPU 配额**:确保 4 线程全开(`eval rate` 提升至 14 tokens/s);\n" +
|
||||||
|
"2. **内存永久常驻**:注入 `OLLAMA_KEEP_ALIVE=-1`,避免 5 分钟超时后重新读盘;\n" +
|
||||||
|
"3. **清空全局默认 Prompt**:消除了默认附带的 2050 历史 Token,读题耗时从 60s 骤降至 0.2s!\n\n" +
|
||||||
|
"```bash\n# 测试模型运行性能\ndocker exec -it 1Panel-ollama-nzre ollama run qwen2.5:1.5b --verbose\n```\n\n" +
|
||||||
|
"> 纯 CPU 跑 7B 参数是物理带宽瓶颈,日常开发推荐搭配 1.5B~3B 模型,速度与逻辑达到最佳平衡!");
|
||||||
|
p2.setAuthor("旺总");
|
||||||
|
p2.setCategoryName("AI & 本地模型");
|
||||||
|
p2.setTags("DeepSeek,Ollama,OpenWebUI,性能调优");
|
||||||
|
p2.setReadTime("6 min");
|
||||||
|
p2.setViews(1048);
|
||||||
|
p2.setLikes(96);
|
||||||
|
|
||||||
|
Post p3 = new Post();
|
||||||
|
p3.setTitle("全栈工程师实战:Spring Boot 3 + MySQL 8.0 持久化与 Gitea CI/CD 自动化交付");
|
||||||
|
p3.setSummary("从零构建工业级 Spring Boot 3 分层架构,结合 MySQL 8.0 数据库持久化存储与私有 Gitea Actions 自动化流水线。");
|
||||||
|
p3.setContent("# 全栈工程化实践:Spring Boot 3 + MySQL 8.0 + CI/CD 自动化\n\n" +
|
||||||
|
"现代化开发的精髓在于**“把数据交给专业数据库持久化,把重复劳动交给自动化流水线”**。\n\n" +
|
||||||
|
"## 全栈架构一览\n" +
|
||||||
|
"```text\n[开发者本地 IDEA] ──git push──▶ [私有 Gitea 仓库]\n │\n (Gitea Actions 触发流水线)\n ▼\n [Docker Compose 全栈构建]\n │\n ┌───────────────────────────┼───────────────────────────┐\n ▼ ▼ ▼\n [MySQL 8.0 容器] [Spring Boot 3 容器] [Nginx 静态反代容器]\n```\n\n" +
|
||||||
|
"## 数据持久化规范\n" +
|
||||||
|
"- 数据文件挂载至命名卷 `mysql_data`,容器重启或重建数据零丢失;\n" +
|
||||||
|
"- Spring Data JPA 实现对象关系映射,自动完成数据库结构维护;\n" +
|
||||||
|
"- 统一 RESTful API 与响应封装,全栈开发得心应手!");
|
||||||
|
p3.setAuthor("旺总");
|
||||||
|
p3.setCategoryName("全栈开发 & CI/CD");
|
||||||
|
p3.setTags("Spring Boot,MySQL,JPA,CI/CD");
|
||||||
|
p3.setReadTime("5 min");
|
||||||
|
p3.setViews(815);
|
||||||
|
p3.setLikes(72);
|
||||||
|
|
||||||
|
postRepository.saveAll(Arrays.asList(p1, p2, p3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.blog.controller;
|
||||||
|
|
||||||
|
import com.example.blog.common.Result;
|
||||||
|
import com.example.blog.entity.Category;
|
||||||
|
import com.example.blog.repository.CategoryRepository;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/categories")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class CategoryController {
|
||||||
|
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
|
||||||
|
public CategoryController(CategoryRepository categoryRepository) {
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Result<List<Category>> listCategories() {
|
||||||
|
return Result.success(categoryRepository.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Result<Category> createCategory(@RequestBody Category category) {
|
||||||
|
if (category.getName() == null || category.getName().trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("分类名称不能为空");
|
||||||
|
}
|
||||||
|
return Result.success(categoryRepository.save(category));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.example.blog.controller;
|
||||||
|
|
||||||
|
import com.example.blog.common.Result;
|
||||||
|
import com.example.blog.entity.Comment;
|
||||||
|
import com.example.blog.service.CommentService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/posts/{postId}/comments")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
private final CommentService commentService;
|
||||||
|
|
||||||
|
public CommentController(CommentService commentService) {
|
||||||
|
this.commentService = commentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Result<List<Comment>> getComments(@PathVariable Long postId) {
|
||||||
|
return Result.success(commentService.getCommentsByPostId(postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Result<Comment> addComment(@PathVariable Long postId, @RequestBody Comment comment) {
|
||||||
|
return Result.success(commentService.addComment(postId, comment));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.blog.controller;
|
||||||
|
|
||||||
|
import com.example.blog.common.Result;
|
||||||
|
import com.example.blog.entity.Post;
|
||||||
|
import com.example.blog.service.PostService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/posts")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class PostController {
|
||||||
|
|
||||||
|
private final PostService postService;
|
||||||
|
|
||||||
|
public PostController(PostService postService) {
|
||||||
|
this.postService = postService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Result<List<Post>> listPosts(
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) String search) {
|
||||||
|
return Result.success(postService.listPosts(category, search));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result<Post> getPost(@PathVariable Long id) {
|
||||||
|
return Result.success(postService.getPostById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Result<Post> createPost(@RequestBody Post post) {
|
||||||
|
return Result.success(postService.createPost(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Result<Post> updatePost(@PathVariable Long id, @RequestBody Post post) {
|
||||||
|
return Result.success(postService.updatePost(id, post));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result<Void> deletePost(@PathVariable Long id) {
|
||||||
|
postService.deletePost(id);
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/like")
|
||||||
|
public Result<Integer> likePost(@PathVariable Long id) {
|
||||||
|
return Result.success(postService.likePost(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.blog.controller;
|
||||||
|
|
||||||
|
import com.example.blog.common.Result;
|
||||||
|
import com.example.blog.service.PostService;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class StatsController {
|
||||||
|
|
||||||
|
private final PostService postService;
|
||||||
|
|
||||||
|
public StatsController(PostService postService) {
|
||||||
|
this.postService = postService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Result<Map<String, Object>> getStats() {
|
||||||
|
return Result.success(postService.getBlogStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.blog.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "category")
|
||||||
|
public class Category {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 50)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void prePersist() {
|
||||||
|
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Category() {}
|
||||||
|
|
||||||
|
public Category(String name, String description) {
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.example.blog.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "comment")
|
||||||
|
public class Comment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "post_id", nullable = false)
|
||||||
|
private Long postId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String author = "访客";
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void prePersist() {
|
||||||
|
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||||
|
if (author == null || author.trim().isEmpty()) author = "匿名访客";
|
||||||
|
}
|
||||||
|
|
||||||
|
public Comment() {}
|
||||||
|
|
||||||
|
public Comment(Long postId, String author, String content) {
|
||||||
|
this.postId = postId;
|
||||||
|
this.author = author;
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public Long getPostId() { return postId; }
|
||||||
|
public void setPostId(Long postId) { this.postId = postId; }
|
||||||
|
public String getAuthor() { return author; }
|
||||||
|
public void setAuthor(String author) { this.author = author; }
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.example.blog.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post")
|
||||||
|
public class Post {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 255)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "LONGTEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String author = "旺总";
|
||||||
|
|
||||||
|
@Column(name = "category_name", length = 50)
|
||||||
|
private String categoryName = "技术杂谈";
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String tags;
|
||||||
|
|
||||||
|
@Column(name = "read_time", length = 20)
|
||||||
|
private String readTime = "3 min";
|
||||||
|
|
||||||
|
private Integer views = 0;
|
||||||
|
|
||||||
|
private Integer likes = 0;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String status = "PUBLISHED";
|
||||||
|
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void prePersist() {
|
||||||
|
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||||
|
if (updatedAt == null) updatedAt = LocalDateTime.now();
|
||||||
|
if (views == null) views = 0;
|
||||||
|
if (likes == null) likes = 0;
|
||||||
|
if (author == null) author = "旺总";
|
||||||
|
if (categoryName == null) categoryName = "技术杂谈";
|
||||||
|
if (readTime == null) readTime = "3 min";
|
||||||
|
if (status == null) status = "PUBLISHED";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
public void preUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Post() {}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
public String getSummary() { return summary; }
|
||||||
|
public void setSummary(String summary) { this.summary = summary; }
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
public String getAuthor() { return author; }
|
||||||
|
public void setAuthor(String author) { this.author = author; }
|
||||||
|
public String getCategoryName() { return categoryName; }
|
||||||
|
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
|
||||||
|
public String getTags() { return tags; }
|
||||||
|
public void setTags(String tags) { this.tags = tags; }
|
||||||
|
public String getReadTime() { return readTime; }
|
||||||
|
public void setReadTime(String readTime) { this.readTime = readTime; }
|
||||||
|
public Integer getViews() { return views; }
|
||||||
|
public void setViews(Integer views) { this.views = views; }
|
||||||
|
public Integer getLikes() { return likes; }
|
||||||
|
public void setLikes(Integer likes) { this.likes = likes; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.blog.repository;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Category;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CategoryRepository extends JpaRepository<Category, Long> {
|
||||||
|
Optional<Category> findByName(String name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.blog.repository;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Comment;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||||
|
List<Comment> findByPostIdOrderByCreatedAtDesc(Long postId);
|
||||||
|
long countByPostId(Long postId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.example.blog.repository;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Post;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface PostRepository extends JpaRepository<Post, Long> {
|
||||||
|
|
||||||
|
List<Post> findAllByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
List<Post> findByCategoryNameOrderByCreatedAtDesc(String categoryName);
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Post p WHERE " +
|
||||||
|
"(LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
|
||||||
|
"LOWER(p.summary) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
|
||||||
|
"LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
|
||||||
|
"ORDER BY p.createdAt DESC")
|
||||||
|
List<Post> searchPosts(@Param("keyword") String keyword);
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Post p WHERE p.categoryName = :categoryName AND " +
|
||||||
|
"(LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
|
||||||
|
"LOWER(p.summary) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
|
||||||
|
"LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
|
||||||
|
"ORDER BY p.createdAt DESC")
|
||||||
|
List<Post> searchPostsWithCategory(@Param("categoryName") String categoryName, @Param("keyword") String keyword);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(p.views), 0) FROM Post p")
|
||||||
|
Long sumViews();
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(p.likes), 0) FROM Post p")
|
||||||
|
Long sumLikes();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.blog.service;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Comment;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CommentService {
|
||||||
|
List<Comment> getCommentsByPostId(Long postId);
|
||||||
|
Comment addComment(Long postId, Comment comment);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.blog.service;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Post;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface PostService {
|
||||||
|
List<Post> listPosts(String category, String search);
|
||||||
|
Post getPostById(Long id);
|
||||||
|
Post createPost(Post post);
|
||||||
|
Post updatePost(Long id, Post post);
|
||||||
|
void deletePost(Long id);
|
||||||
|
int likePost(Long id);
|
||||||
|
Map<String, Object> getBlogStats();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.blog.service.impl;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Comment;
|
||||||
|
import com.example.blog.repository.CommentRepository;
|
||||||
|
import com.example.blog.repository.PostRepository;
|
||||||
|
import com.example.blog.service.CommentService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CommentServiceImpl implements CommentService {
|
||||||
|
|
||||||
|
private final CommentRepository commentRepository;
|
||||||
|
private final PostRepository postRepository;
|
||||||
|
|
||||||
|
public CommentServiceImpl(CommentRepository commentRepository, PostRepository postRepository) {
|
||||||
|
this.commentRepository = commentRepository;
|
||||||
|
this.postRepository = postRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Comment> getCommentsByPostId(Long postId) {
|
||||||
|
return commentRepository.findByPostIdOrderByCreatedAtDesc(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Comment addComment(Long postId, Comment comment) {
|
||||||
|
if (!postRepository.existsById(postId)) {
|
||||||
|
throw new RuntimeException("评论失败,文章不存在,ID: " + postId);
|
||||||
|
}
|
||||||
|
if (comment.getContent() == null || comment.getContent().trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("评论内容不能为空");
|
||||||
|
}
|
||||||
|
comment.setPostId(postId);
|
||||||
|
if (comment.getAuthor() == null || comment.getAuthor().trim().isEmpty()) {
|
||||||
|
comment.setAuthor("热心技术网友");
|
||||||
|
}
|
||||||
|
return commentRepository.save(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.example.blog.service.impl;
|
||||||
|
|
||||||
|
import com.example.blog.entity.Post;
|
||||||
|
import com.example.blog.repository.CategoryRepository;
|
||||||
|
import com.example.blog.repository.PostRepository;
|
||||||
|
import com.example.blog.service.PostService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PostServiceImpl implements PostService {
|
||||||
|
|
||||||
|
private final PostRepository postRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
|
||||||
|
public PostServiceImpl(PostRepository postRepository, CategoryRepository categoryRepository) {
|
||||||
|
this.postRepository = postRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Post> listPosts(String category, String search) {
|
||||||
|
boolean hasCategory = category != null && !category.trim().isEmpty() && !"全部".equals(category);
|
||||||
|
boolean hasSearch = search != null && !search.trim().isEmpty();
|
||||||
|
|
||||||
|
if (hasCategory && hasSearch) {
|
||||||
|
return postRepository.searchPostsWithCategory(category.trim(), search.trim());
|
||||||
|
} else if (hasCategory) {
|
||||||
|
return postRepository.findByCategoryNameOrderByCreatedAtDesc(category.trim());
|
||||||
|
} else if (hasSearch) {
|
||||||
|
return postRepository.searchPosts(search.trim());
|
||||||
|
} else {
|
||||||
|
return postRepository.findAllByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Post getPostById(Long id) {
|
||||||
|
Post post = postRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("文章不存在,ID: " + id));
|
||||||
|
post.setViews(post.getViews() + 1);
|
||||||
|
return postRepository.save(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Post createPost(Post post) {
|
||||||
|
if (post.getTitle() == null || post.getTitle().trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("文章标题不能为空");
|
||||||
|
}
|
||||||
|
if (post.getContent() == null || post.getContent().trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("文章正文内容不能为空");
|
||||||
|
}
|
||||||
|
if (post.getSummary() == null || post.getSummary().trim().isEmpty()) {
|
||||||
|
String content = post.getContent().replaceAll("[#*`>-]", "");
|
||||||
|
post.setSummary(content.length() > 120 ? content.substring(0, 120) + "..." : content);
|
||||||
|
}
|
||||||
|
if (post.getReadTime() == null || post.getReadTime().trim().isEmpty()) {
|
||||||
|
int minutes = Math.max(2, Math.round(post.getContent().length() / 350f));
|
||||||
|
post.setReadTime(minutes + " min");
|
||||||
|
}
|
||||||
|
return postRepository.save(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Post updatePost(Long id, Post post) {
|
||||||
|
Post existing = postRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("文章不存在,ID: " + id));
|
||||||
|
if (post.getTitle() != null) existing.setTitle(post.getTitle());
|
||||||
|
if (post.getSummary() != null) existing.setSummary(post.getSummary());
|
||||||
|
if (post.getContent() != null) existing.setContent(post.getContent());
|
||||||
|
if (post.getCategoryName() != null) existing.setCategoryName(post.getCategoryName());
|
||||||
|
if (post.getTags() != null) existing.setTags(post.getTags());
|
||||||
|
if (post.getAuthor() != null) existing.setAuthor(post.getAuthor());
|
||||||
|
return postRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deletePost(Long id) {
|
||||||
|
if (!postRepository.existsById(id)) {
|
||||||
|
throw new RuntimeException("文章不存在,ID: " + id);
|
||||||
|
}
|
||||||
|
postRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int likePost(Long id) {
|
||||||
|
Post post = postRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("文章不存在,ID: " + id));
|
||||||
|
post.setLikes(post.getLikes() + 1);
|
||||||
|
postRepository.save(post);
|
||||||
|
return post.getLikes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, Object> getBlogStats() {
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
stats.put("totalPosts", postRepository.count());
|
||||||
|
stats.put("totalViews", postRepository.sumViews());
|
||||||
|
stats.put("totalLikes", postRepository.sumLikes());
|
||||||
|
stats.put("totalCategories", categoryRepository.count());
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: my-blog-backend
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${DB_HOST:mysql}:${DB_PORT:3306}/${DB_NAME:my_blog}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
|
||||||
|
username: ${DB_USER:root}
|
||||||
|
password: ${DB_PASSWORD:root123456}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
hikari:
|
||||||
|
connection-timeout: 30000
|
||||||
|
maximum-pool-size: 10
|
||||||
|
minimum-idle: 2
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
dialect: org.hibernate.dialect.MySQLDialect
|
||||||
|
open-in-view: false
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info
|
||||||
@@ -1,9 +1,43 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: blog-mysql
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root123456
|
||||||
|
MYSQL_DATABASE: my_blog
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
command:
|
||||||
|
- --default-authentication-plugin=mysql_native_password
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot123456"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: blog-backend
|
container_name: blog-backend
|
||||||
restart: always
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: my_blog
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: root123456
|
||||||
|
TZ: Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "8089:8080"
|
- "8089:8080"
|
||||||
|
|
||||||
@@ -11,5 +45,10 @@ services:
|
|||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: blog-frontend
|
container_name: blog-frontend
|
||||||
restart: always
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "8088:80"
|
- "8088:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
|||||||
+214
-162
@@ -33,7 +33,6 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
[v-cloak] { display: none; }
|
[v-cloak] { display: none; }
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
|
|
||||||
<div id="app" v-cloak class="flex flex-col min-h-screen">
|
<div id="app" v-cloak class="flex flex-col min-h-screen">
|
||||||
|
|
||||||
<!-- 顶部导航栏 (借鉴 Tailwind Next.js Starter Blog 的轻量极简毛玻璃质感) -->
|
<!-- 顶部导航栏 -->
|
||||||
<header class="sticky top-0 z-40 backdrop-blur-md bg-white/80 dark:bg-slate-900/80 border-b border-slate-200/80 dark:border-slate-800 transition-colors">
|
<header class="sticky top-0 z-40 backdrop-blur-md bg-white/80 dark:bg-slate-900/80 border-b border-slate-200/80 dark:border-slate-800 transition-colors">
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||||
<!-- 品牌 Logo -->
|
<!-- 品牌 Logo -->
|
||||||
@@ -66,7 +65,7 @@
|
|||||||
<div class="font-bold text-base tracking-tight flex items-center gap-2">
|
<div class="font-bold text-base tracking-tight flex items-center gap-2">
|
||||||
<span>旺总的技术全栈博客</span>
|
<span>旺总的技术全栈博客</span>
|
||||||
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-950/80 dark:text-sky-300 border border-sky-200 dark:border-sky-800">
|
<span class="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-950/80 dark:text-sky-300 border border-sky-200 dark:border-sky-800">
|
||||||
Pro
|
SpringBoot 3 + MySQL 8.0
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400 hidden sm:block">Full-Stack · Cloud-Native · DevOps</p>
|
<p class="text-xs text-slate-500 dark:text-slate-400 hidden sm:block">Full-Stack · Cloud-Native · DevOps</p>
|
||||||
@@ -75,11 +74,11 @@
|
|||||||
|
|
||||||
<!-- 右侧操作区 -->
|
<!-- 右侧操作区 -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- 后端连接状态小药丸 -->
|
<!-- 数据库与后端连接指示器 -->
|
||||||
<div :class="backendConnected ? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800' : 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800'"
|
<div :class="backendConnected ? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800' : 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-800'"
|
||||||
class="hidden md:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border font-medium">
|
class="hidden md:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border font-medium">
|
||||||
<span class="w-2 h-2 rounded-full" :class="backendConnected ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'"></span>
|
<span class="w-2 h-2 rounded-full" :class="backendConnected ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'"></span>
|
||||||
<span>{{ backendConnected ? 'Spring Boot 容器已联通' : '离线预览模式' }}</span>
|
<span>{{ backendConnected ? 'MySQL 8.0 + SpringBoot 3 已联通' : '离线预览模式' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 写文章按钮 -->
|
<!-- 写文章按钮 -->
|
||||||
@@ -103,7 +102,7 @@
|
|||||||
<!-- 主体容器 -->
|
<!-- 主体容器 -->
|
||||||
<main class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 py-8">
|
<main class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 py-8">
|
||||||
|
|
||||||
<!-- Hero 博主名片区域 (借鉴 AstroPaper & GitHub Profile 设计) -->
|
<!-- Hero 博主名片区域 -->
|
||||||
<section class="mb-10 p-6 sm:p-8 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm relative overflow-hidden">
|
<section class="mb-10 p-6 sm:p-8 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm relative overflow-hidden">
|
||||||
<div class="absolute -right-16 -top-16 w-56 h-56 bg-gradient-to-br from-sky-400/20 to-indigo-500/10 rounded-full blur-2xl pointer-events-none"></div>
|
<div class="absolute -right-16 -top-16 w-56 h-56 bg-gradient-to-br from-sky-400/20 to-indigo-500/10 rounded-full blur-2xl pointer-events-none"></div>
|
||||||
|
|
||||||
@@ -121,15 +120,15 @@
|
|||||||
<span class="px-2.5 py-0.5 text-xs font-semibold rounded-md bg-indigo-50 text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300 border border-indigo-200/60 dark:border-indigo-800">全栈工程师</span>
|
<span class="px-2.5 py-0.5 text-xs font-semibold rounded-md bg-indigo-50 text-indigo-700 dark:bg-indigo-950/60 dark:text-indigo-300 border border-indigo-200/60 dark:border-indigo-800">全栈工程师</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm sm:text-base text-slate-600 dark:text-slate-300">
|
<p class="mt-1 text-sm sm:text-base text-slate-600 dark:text-slate-300">
|
||||||
专注 Spring Boot 微服务架构 · Docker / K8s 云原生容器化 · CI/CD 自动化交付
|
专注 Spring Boot 3 企业级架构 · MySQL 8.0 持久化 · Docker / K8s 容器编排 · CI/CD 自动化流水线
|
||||||
</p>
|
</p>
|
||||||
<!-- 技术栈徽章 -->
|
<!-- 技术栈徽章 -->
|
||||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">⚡ Spring Boot 3</span>
|
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🐬 MySQL 8.0 持久化</span>
|
||||||
|
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">⚡ Spring Boot 3 + JPA</span>
|
||||||
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🐳 Docker Compose</span>
|
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🐳 Docker Compose</span>
|
||||||
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🌿 Vue 3 / Vite</span>
|
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🚀 Gitea Actions CI/CD</span>
|
||||||
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🚀 Gitea Actions</span>
|
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🤖 DeepSeek R1 本地化</span>
|
||||||
<span class="px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-mono">🤖 DeepSeek R1</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,29 +136,29 @@
|
|||||||
<!-- 数据统计面板卡片 -->
|
<!-- 数据统计面板卡片 -->
|
||||||
<div class="flex items-center gap-4 sm:gap-6 bg-slate-50 dark:bg-slate-800/60 p-3 sm:p-4 rounded-xl border border-slate-200/60 dark:border-slate-700/60 self-start md:self-auto">
|
<div class="flex items-center gap-4 sm:gap-6 bg-slate-50 dark:bg-slate-800/60 p-3 sm:p-4 rounded-xl border border-slate-200/60 dark:border-slate-700/60 self-start md:self-auto">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-xl sm:text-2xl font-black text-sky-600 dark:text-sky-400">{{ stats.totalPosts || posts.length }}</div>
|
<div class="text-xl sm:text-2xl font-black text-sky-600 dark:text-sky-400">{{ stats.totalPosts }}</div>
|
||||||
<div class="text-xs text-slate-500 dark:text-slate-400">公开文章</div>
|
<div class="text-xs text-slate-500 dark:text-slate-400">文章总数</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-8 bg-slate-200 dark:bg-slate-700"></div>
|
<div class="w-px h-8 bg-slate-200 dark:bg-slate-700"></div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-xl sm:text-2xl font-black text-slate-800 dark:text-slate-200">{{ stats.totalViews || 2061 }}</div>
|
<div class="text-xl sm:text-2xl font-black text-slate-800 dark:text-slate-200">{{ stats.totalViews }}</div>
|
||||||
<div class="text-xs text-slate-500 dark:text-slate-400">阅读总次</div>
|
<div class="text-xs text-slate-500 dark:text-slate-400">累计浏览</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-8 bg-slate-200 dark:bg-slate-700"></div>
|
<div class="w-px h-8 bg-slate-200 dark:bg-slate-700"></div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-xl sm:text-2xl font-black text-rose-500">{{ stats.totalLikes || 165 }}</div>
|
<div class="text-xl sm:text-2xl font-black text-rose-500">{{ stats.totalLikes }}</div>
|
||||||
<div class="text-xs text-slate-500 dark:text-slate-400">点赞认可</div>
|
<div class="text-xs text-slate-500 dark:text-slate-400">点赞认可</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 筛选与搜索控制栏 (借鉴 Halo & Fluid 主题工具条) -->
|
<!-- 筛选与搜索控制栏 -->
|
||||||
<div class="mb-6 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
|
<div class="mb-6 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
|
||||||
<!-- 分类选项卡 -->
|
<!-- 分类选项卡 -->
|
||||||
<div class="flex items-center gap-1.5 overflow-x-auto pb-2 sm:pb-0 text-sm no-scrollbar">
|
<div class="flex items-center gap-1.5 overflow-x-auto pb-2 sm:pb-0 text-sm no-scrollbar">
|
||||||
<button v-for="cat in categories" :key="cat"
|
<button v-for="cat in categories" :key="cat"
|
||||||
@click="selectedCategory = cat"
|
@click="selectCategory(cat)"
|
||||||
:class="selectedCategory === cat
|
:class="selectedCategory === cat
|
||||||
? 'bg-slate-900 text-white dark:bg-sky-600 dark:text-white shadow-sm'
|
? 'bg-slate-900 text-white dark:bg-sky-600 dark:text-white shadow-sm'
|
||||||
: 'bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-800'"
|
: 'bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-800'"
|
||||||
@@ -171,6 +170,7 @@
|
|||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<div class="relative w-full sm:w-64">
|
<div class="relative w-full sm:w-64">
|
||||||
<input v-model="searchKeyword"
|
<input v-model="searchKeyword"
|
||||||
|
@input="onSearchInput"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索文章标题或关键词..."
|
placeholder="搜索文章标题或关键词..."
|
||||||
class="w-full pl-9 pr-4 py-1.5 text-sm rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 focus:outline-none focus:ring-2 focus:ring-sky-500/50 dark:focus:ring-sky-400/50 transition-all">
|
class="w-full pl-9 pr-4 py-1.5 text-sm rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 focus:outline-none focus:ring-2 focus:ring-sky-500/50 dark:focus:ring-sky-400/50 transition-all">
|
||||||
@@ -178,18 +178,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文章列表区域 (模仿 GitHub Tailwind Starter Blog 经典卡片流) -->
|
<!-- 文章列表区域 -->
|
||||||
<div v-if="filteredPosts.length > 0" class="space-y-4">
|
<div v-if="posts.length > 0" class="space-y-4">
|
||||||
<article v-for="post in filteredPosts" :key="post.id"
|
<article v-for="post in posts" :key="post.id"
|
||||||
class="group p-5 sm:p-6 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm hover:shadow-md hover:border-sky-500/40 dark:hover:border-sky-500/40 transition-all cursor-pointer"
|
class="group p-5 sm:p-6 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200/80 dark:border-slate-800 shadow-sm hover:shadow-md hover:border-sky-500/40 dark:hover:border-sky-500/40 transition-all cursor-pointer"
|
||||||
@click="openPostDetail(post)">
|
@click="openPostDetail(post)">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1 mb-2">
|
<div class="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1 mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-sky-50 text-sky-700 dark:bg-sky-950/70 dark:text-sky-300 border border-sky-100 dark:border-sky-900/50">
|
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-sky-50 text-sky-700 dark:bg-sky-950/70 dark:text-sky-300 border border-sky-100 dark:border-sky-900/50">
|
||||||
{{ post.category || '技术' }}
|
{{ post.categoryName || '技术杂谈' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-slate-400">·</span>
|
<span class="text-xs text-slate-400">·</span>
|
||||||
<span class="text-xs text-slate-500 dark:text-slate-400">📅 {{ post.date }}</span>
|
<span class="text-xs text-slate-500 dark:text-slate-400">📅 {{ formatDate(post.createdAt) }}</span>
|
||||||
<span class="text-xs text-slate-400">·</span>
|
<span class="text-xs text-slate-400">·</span>
|
||||||
<span class="text-xs text-slate-500 dark:text-slate-400">⏱️ {{ post.readTime || '3 min' }}</span>
|
<span class="text-xs text-slate-500 dark:text-slate-400">⏱️ {{ post.readTime || '3 min' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,14 +202,14 @@
|
|||||||
|
|
||||||
<!-- 摘要 -->
|
<!-- 摘要 -->
|
||||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300 line-clamp-2 leading-relaxed">
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300 line-clamp-2 leading-relaxed">
|
||||||
{{ post.summary || post.content.substring(0, 140) }}...
|
{{ post.summary || post.content.substring(0, 140) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 底部标签与互动 -->
|
<!-- 底部标签与互动 -->
|
||||||
<div class="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
<div class="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800/80 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||||
<!-- 标签群 -->
|
<!-- 标签群 -->
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
<span v-for="tag in (post.tags || [])" :key="tag"
|
<span v-for="tag in parseTags(post.tags)" :key="tag"
|
||||||
class="text-slate-500 dark:text-slate-400 hover:text-sky-500">
|
class="text-slate-500 dark:text-slate-400 hover:text-sky-500">
|
||||||
#{{ tag }}
|
#{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
|
||||||
{{ post.views || 1 }}
|
{{ post.views || 0 }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button @click.stop="likePost(post)"
|
<button @click.stop="likePost(post)"
|
||||||
@@ -240,11 +240,11 @@
|
|||||||
<div v-else class="text-center py-16 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800">
|
<div v-else class="text-center py-16 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||||
<div class="text-4xl mb-3">🔍</div>
|
<div class="text-4xl mb-3">🔍</div>
|
||||||
<h3 class="text-base font-semibold text-slate-800 dark:text-slate-200">没有找到相关文章</h3>
|
<h3 class="text-base font-semibold text-slate-800 dark:text-slate-200">没有找到相关文章</h3>
|
||||||
<p class="text-xs text-slate-500 mt-1">换个搜索词或点击右上方“发布文章”添加一篇吧!</p>
|
<p class="text-xs text-slate-500 mt-1">换个搜索词或点击右上方“发布文章”向 MySQL 写入一篇吧!</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 文章详情阅读模态框 (模仿 AstroPaper 干净排版) -->
|
<!-- 文章详情阅读模态框 -->
|
||||||
<div v-if="activePost"
|
<div v-if="activePost"
|
||||||
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 sm:p-6"
|
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 sm:p-6"
|
||||||
@click.self="activePost = null">
|
@click.self="activePost = null">
|
||||||
@@ -253,14 +253,19 @@
|
|||||||
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-sky-50 text-sky-700 dark:bg-sky-950/70 dark:text-sky-300">
|
<span class="px-2 py-0.5 text-xs font-semibold rounded bg-sky-50 text-sky-700 dark:bg-sky-950/70 dark:text-sky-300">
|
||||||
{{ activePost.category }}
|
{{ activePost.categoryName }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-slate-400">·</span>
|
<span class="text-xs text-slate-400">·</span>
|
||||||
<span class="text-xs text-slate-500">📅 {{ activePost.date }}</span>
|
<span class="text-xs text-slate-500">📅 {{ formatDate(activePost.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="deletePost(activePost)" class="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-950/40 transition-colors" title="删除文章">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
<button @click="activePost = null" class="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click="activePost = null" class="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模态框正文 -->
|
<!-- 模态框正文 -->
|
||||||
@@ -286,24 +291,60 @@
|
|||||||
<button @click="likePost(activePost)"
|
<button @click="likePost(activePost)"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-50 text-rose-600 dark:bg-rose-950/40 dark:text-rose-400 border border-rose-200 dark:border-rose-900 hover:scale-105 active:scale-95 transition-transform font-medium text-sm">
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-50 text-rose-600 dark:bg-rose-950/40 dark:text-rose-400 border border-rose-200 dark:border-rose-900 hover:scale-105 active:scale-95 transition-transform font-medium text-sm">
|
||||||
<svg class="w-4 h-4 fill-rose-500" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"></path></svg>
|
<svg class="w-4 h-4 fill-rose-500" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"></path></svg>
|
||||||
<span>给文章点赞 ({{ activePost.likes }})</span>
|
<span>点赞支持 ({{ activePost.likes }})</span>
|
||||||
</button>
|
|
||||||
<button @click="activePost = null" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
|
||||||
返回文章列表
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 评论专区 (连接 MySQL comment 表) -->
|
||||||
|
<div class="mt-10 pt-6 border-t border-slate-200 dark:border-slate-800">
|
||||||
|
<h3 class="text-base font-bold mb-4 flex items-center gap-2">
|
||||||
|
<span>💬 读者交流区</span>
|
||||||
|
<span class="text-xs text-slate-500 font-normal">({{ comments.length }} 条持久化评论)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- 发表评论表单 -->
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-800/50 p-4 rounded-xl border border-slate-200/60 dark:border-slate-700/60 mb-6 space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input v-model="newComment.author" type="text" placeholder="你的昵称 (默认: 访客)"
|
||||||
|
class="px-3 py-1.5 rounded-lg bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs w-48 focus:outline-none focus:ring-1 focus:ring-sky-500">
|
||||||
|
</div>
|
||||||
|
<textarea v-model="newComment.content" rows="2" placeholder="写下你的技术见解或想法..."
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs focus:outline-none focus:ring-1 focus:ring-sky-500"></textarea>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button @click="submitComment" :disabled="submittingComment"
|
||||||
|
class="px-3.5 py-1.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white text-xs font-semibold shadow-sm transition-all">
|
||||||
|
{{ submittingComment ? '提交中...' : '发表评论' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评论列表 -->
|
||||||
|
<div v-if="comments.length > 0" class="space-y-3">
|
||||||
|
<div v-for="c in comments" :key="c.id"
|
||||||
|
class="p-3.5 rounded-xl bg-slate-50/80 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 text-xs">
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<span class="font-bold text-slate-900 dark:text-slate-100">👤 {{ c.author }}</span>
|
||||||
|
<span class="text-slate-400">{{ formatDate(c.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-700 dark:text-slate-300 leading-relaxed">{{ c.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-6 text-xs text-slate-400">
|
||||||
|
暂无评论,快来抢沙发吧!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 发布文章写作工作室模态框 (模仿 Ghost / Notion 极简写作台) -->
|
<!-- 发布文章写作工作室模态框 (直写 MySQL) -->
|
||||||
<div v-if="showPublishModal"
|
<div v-if="showPublishModal"
|
||||||
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 sm:p-6"
|
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 sm:p-6"
|
||||||
@click.self="showPublishModal = false">
|
@click.self="showPublishModal = false">
|
||||||
<div class="w-full max-w-2xl bg-white dark:bg-slate-900 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
<div class="w-full max-w-2xl bg-white dark:bg-slate-900 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
||||||
<h3 class="font-bold text-lg text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
<h3 class="font-bold text-lg text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||||
<span>✍️ 撰写并发布新博客</span>
|
<span>✍️ 撰写并持久化发布到 MySQL</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button @click="showPublishModal = false" class="text-slate-400 hover:text-slate-600">
|
<button @click="showPublishModal = false" class="text-slate-400 hover:text-slate-600">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
@@ -320,7 +361,7 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-medium mb-1 text-slate-700 dark:text-slate-300">技术分类</label>
|
<label class="block font-medium mb-1 text-slate-700 dark:text-slate-300">技术分类</label>
|
||||||
<select v-model="newPost.category"
|
<select v-model="newPost.categoryName"
|
||||||
class="w-full px-3.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
|
class="w-full px-3.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
|
||||||
<option value="云原生 & Linux">云原生 & Linux</option>
|
<option value="云原生 & Linux">云原生 & Linux</option>
|
||||||
<option value="Java & Spring Boot">Java & Spring Boot</option>
|
<option value="Java & Spring Boot">Java & Spring Boot</option>
|
||||||
@@ -330,21 +371,21 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-medium mb-1 text-slate-700 dark:text-slate-300">作者签名</label>
|
<label class="block font-medium mb-1 text-slate-700 dark:text-slate-300">标签 (逗号分隔)</label>
|
||||||
<input v-model="newPost.author" type="text" placeholder="默认: 旺总"
|
<input v-model="newPost.tags" type="text" placeholder="例如: SpringBoot,MySQL,Docker"
|
||||||
class="w-full px-3.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
|
class="w-full px-3.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-medium mb-1 text-slate-700 dark:text-slate-300">一句话简要摘要</label>
|
<label class="block font-medium mb-1 text-slate-700 dark:text-slate-300">一句话摘要</label>
|
||||||
<input v-model="newPost.summary" type="text" placeholder="简述本文的核心要点与阅读价值..."
|
<input v-model="newPost.summary" type="text" placeholder="简述本文的核心要点与阅读价值..."
|
||||||
class="w-full px-3.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
|
class="w-full px-3.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<label class="font-medium text-slate-700 dark:text-slate-300">文章内容 (支持标准 Markdown 语法) *</label>
|
<label class="font-medium text-slate-700 dark:text-slate-300">文章内容 (支持 Markdown) *</label>
|
||||||
<span class="text-xs text-slate-400">支持代码块高亮及标题格式</span>
|
<span class="text-xs text-slate-400">支持代码块高亮及标题格式</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea v-model="newPost.content" rows="7" placeholder="# 正文标题 写下你的技术思考或教程步骤... ```java // 代码示例 ```"
|
<textarea v-model="newPost.content" rows="7" placeholder="# 正文标题 写下你的技术思考或教程步骤... ```java // 代码示例 ```"
|
||||||
@@ -358,7 +399,7 @@
|
|||||||
<button @click="publishPost" :disabled="publishing"
|
<button @click="publishPost" :disabled="publishing"
|
||||||
class="px-5 py-2 rounded-lg bg-sky-600 hover:bg-sky-700 disabled:opacity-50 text-white font-semibold shadow-md shadow-sky-600/30 flex items-center gap-2">
|
class="px-5 py-2 rounded-lg bg-sky-600 hover:bg-sky-700 disabled:opacity-50 text-white font-semibold shadow-md shadow-sky-600/30 flex items-center gap-2">
|
||||||
<span v-if="publishing" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
<span v-if="publishing" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||||
<span>{{ publishing ? '发布中...' : '立即发布到线上' }}</span>
|
<span>{{ publishing ? '写入 MySQL 中...' : '持久化发布' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,9 +414,9 @@
|
|||||||
<span class="text-sky-600 dark:text-sky-400 font-mono">Google Cloud 台湾节点</span>
|
<span class="text-sky-600 dark:text-sky-400 font-mono">Google Cloud 台湾节点</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span>Spring Boot 3</span>
|
<span>Spring Boot 3 + JPA</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Docker Compose</span>
|
<span>MySQL 8.0</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Gitea CI/CD</span>
|
<span>Gitea CI/CD</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,10 +435,13 @@
|
|||||||
const isDark = ref(false);
|
const isDark = ref(false);
|
||||||
const backendConnected = ref(false);
|
const backendConnected = ref(false);
|
||||||
const posts = ref([]);
|
const posts = ref([]);
|
||||||
const stats = ref({ totalPosts: 0, totalViews: 0, totalLikes: 0 });
|
const stats = ref({ totalPosts: 0, totalViews: 0, totalLikes: 0, totalCategories: 0 });
|
||||||
const selectedCategory = ref('全部');
|
const selectedCategory = ref('全部');
|
||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const activePost = ref(null);
|
const activePost = ref(null);
|
||||||
|
const comments = ref([]);
|
||||||
|
const newComment = ref({ author: '访客', content: '' });
|
||||||
|
const submittingComment = ref(false);
|
||||||
const showPublishModal = ref(false);
|
const showPublishModal = ref(false);
|
||||||
const publishing = ref(false);
|
const publishing = ref(false);
|
||||||
|
|
||||||
@@ -405,104 +449,119 @@
|
|||||||
|
|
||||||
const newPost = ref({
|
const newPost = ref({
|
||||||
title: '',
|
title: '',
|
||||||
category: '全栈开发 & CI/CD',
|
categoryName: '全栈开发 & CI/CD',
|
||||||
author: '旺总',
|
author: '旺总',
|
||||||
|
tags: 'SpringBoot,MySQL',
|
||||||
summary: '',
|
summary: '',
|
||||||
content: ''
|
content: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// 本地优雅备用数据(即使脱机双击打开 HTML,也永远不会出现丑陋的红字报错)
|
// 请求后端文章列表
|
||||||
const fallbackPosts = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: '云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel',
|
|
||||||
summary: '本文记录了如何通过 GCP 赠金在亚洲低延迟机房部署 4核16G 高性能云服务器,并使用现代化容器面板 1Panel 进行运维管理实录。',
|
|
||||||
content: '# 云原生实践:在 Google Cloud 台湾节点搭建高性能服务器\n\n在挑选云服务器的过程中,综合对比了各大厂商的免费策略与性能表现,最终选择了 **Google Cloud 台湾节点 (`asia-east1`)**。\n\n## 核心硬件与网络配置\n- **规格**:`e2-standard-4`(4 vCPU,16 GB 内存)\n- **系统**:Ubuntu 22.04 LTS (x86_64)\n- **延迟**:到中国大陆仅 30~50ms,操作极为丝滑。\n\n## 为什么采用 1Panel 面板?\n相比传统臃肿的面料,1Panel 深度拥抱 **Docker 容器化**,所有应用(MySQL、OpenResty、Gitea)完全容器隔离,真正做到“环境随用随建,数据挂载持久化”。\n\n```bash\n# 快速检查 Docker 运行状态\ndocker ps --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}"\n```\n\n> 💡 **运维心得**:永远在云平台控制台统一放行防火墙端口,切勿盲目在系统内部启用 UFW,避免失联风险。',
|
|
||||||
author: '旺总',
|
|
||||||
date: '2026-10-22',
|
|
||||||
category: '云原生 & Linux',
|
|
||||||
tags: ['GCP', 'Ubuntu', '1Panel', 'Linux'],
|
|
||||||
readTime: '4 min',
|
|
||||||
views: 528,
|
|
||||||
likes: 42
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: '探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优',
|
|
||||||
summary: '深度解析在纯 CPU 服务器上运行 DeepSeek R1 和通义千问 2.5 的真实表现,揭开 2050 Token 预填充延迟背后的机制。',
|
|
||||||
content: '# 探索本地大模型:从 Ollama 到 Open WebUI 性能调优实战\n\n近期 DeepSeek R1 推理思考模型火遍全球,我们在 4核16G 的服务器上进行了本地大模型的全链路压测。\n\n## 1. 架构搭建\n- **模型发动机**:`Ollama` 容器\n- **交互界面**:`Open WebUI` 容器\n- **网络通信**:通过 Docker 网关 `http://172.17.0.1:11434` 极速直连\n\n## 2. 性能排查与调优秘籍\n测试过程中遇到了“每次提问卡顿 50 秒”的问题,经过 `top` 分析和指标剖析:\n1. **解除容器 CPU 配额**:确保 4 线程全开(`eval rate` 提升至 14 tokens/s);\n2. **内存永久常驻**:注入 `OLLAMA_KEEP_ALIVE=-1`,避免 5 分钟超时后重新读盘;\n3. **清空全局默认 Prompt**:消除了默认附带的 2050 历史 Token,读题耗时从 60s 骤降至 0.2s!\n\n```bash\n# 测试模型运行性能\ndocker exec -it 1Panel-ollama-nzre ollama run qwen2.5:1.5b --verbose\n```\n\n> 纯 CPU 跑 7B 参数是物理带宽瓶颈,日常开发推荐搭配 1.5B~3B 模型,速度与逻辑达到最佳平衡!',
|
|
||||||
author: '旺总',
|
|
||||||
date: '2026-10-22',
|
|
||||||
category: 'AI & 本地模型',
|
|
||||||
tags: ['DeepSeek', 'Ollama', 'OpenWebUI', '调优'],
|
|
||||||
readTime: '6 min',
|
|
||||||
views: 892,
|
|
||||||
likes: 68
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: '全栈工程师实战:Spring Boot 3 + Vue 3 博客与 Gitea CI/CD 自动化流水线',
|
|
||||||
summary: '从零手写前后端分离架构,配合私有 Gitea 仓库与自动化部署脚本,实现 push 代码后全自动打包上线。',
|
|
||||||
content: '# 全栈工程化实践:Spring Boot 3 + Vue 3 + CI/CD 自动化\n\n现代化开发的精髓在于**“把重复劳动交给自动化”**。我们搭建了一套小而美但功能极其完备的企业级全栈流水线。\n\n## 全栈架构一览\n```text\n[开发者本地 IDEA] ──git push──▶ [私有 Gitea 仓库]\n │\n (Webhook 触发流水线)\n ▼\n [deploy.sh 自动化脚本]\n │\n ┌─────────────┴─────────────┐\n ▼ ▼\n [Spring Boot 3 容器] [Nginx 静态反代容器]\n```\n\n## Nginx 反向代理配置关键点\n前端容器中通过反代 `/api/` 到后端服务容器,天然解决跨域和外部端口分散问题:\n\n```nginx\nlocation /api/ {\n proxy_pass http://backend:8080/api/;\n proxy_set_header Host $host;\n}\n```\n\n从此,改完代码只需一条 `git push`,剩下的事情交给服务器自动化即可!',
|
|
||||||
author: '旺总',
|
|
||||||
date: '2026-10-22',
|
|
||||||
category: '全栈开发 & CI/CD',
|
|
||||||
tags: ['Spring Boot', 'Vue 3', 'CI/CD', 'Docker Compose'],
|
|
||||||
readTime: '5 min',
|
|
||||||
views: 641,
|
|
||||||
likes: 55
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// 获取后端数据
|
|
||||||
async function fetchPosts() {
|
async function fetchPosts() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/posts`);
|
const params = new URLSearchParams();
|
||||||
|
if (selectedCategory.value && selectedCategory.value !== '全部') {
|
||||||
|
params.append('category', selectedCategory.value);
|
||||||
|
}
|
||||||
|
if (searchKeyword.value.trim()) {
|
||||||
|
params.append('search', searchKeyword.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/posts?${params.toString()}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const result = await res.json();
|
||||||
posts.value = data;
|
posts.value = result.data || result;
|
||||||
backendConnected.value = true;
|
backendConnected.value = true;
|
||||||
fetchStats();
|
fetchStats();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('后端 API 未就绪,切换到高仿真离线模式:', e);
|
console.warn('后端 API 未就绪:', e);
|
||||||
}
|
}
|
||||||
posts.value = fallbackPosts;
|
|
||||||
backendConnected.value = false;
|
backendConnected.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取统计信息
|
// 获取全栈统计数据
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/stats`);
|
const res = await fetch(`${API_BASE}/stats`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
stats.value = await res.json();
|
const result = await res.json();
|
||||||
|
stats.value = result.data || result;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// ignore
|
}
|
||||||
|
|
||||||
|
function selectCategory(cat) {
|
||||||
|
selectedCategory.value = cat;
|
||||||
|
fetchPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimer = null;
|
||||||
|
function onSearchInput() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
fetchPosts();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看文章详情并加载评论
|
||||||
|
async function openPostDetail(post) {
|
||||||
|
activePost.value = post;
|
||||||
|
post.views = (post.views || 0) + 1;
|
||||||
|
fetchComments(post.id);
|
||||||
|
if (backendConnected.value) {
|
||||||
|
fetch(`${API_BASE}/posts/${post.id}`).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 筛选文章
|
// 加载该文章的评论
|
||||||
const filteredPosts = computed(() => {
|
async function fetchComments(postId) {
|
||||||
return posts.value.filter(p => {
|
try {
|
||||||
const matchCat = selectedCategory.value === '全部' || p.category === selectedCategory.value;
|
const res = await fetch(`${API_BASE}/posts/${postId}/comments`);
|
||||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
if (res.ok) {
|
||||||
const matchSearch = !keyword ||
|
const result = await res.json();
|
||||||
p.title.toLowerCase().includes(keyword) ||
|
comments.value = result.data || [];
|
||||||
(p.summary && p.summary.toLowerCase().includes(keyword)) ||
|
}
|
||||||
(p.content && p.content.toLowerCase().includes(keyword));
|
} catch (e) {
|
||||||
return matchCat && matchSearch;
|
comments.value = [];
|
||||||
});
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// 阅读文章
|
// 提交评论
|
||||||
function openPostDetail(post) {
|
async function submitComment() {
|
||||||
activePost.value = post;
|
if (!newComment.value.content.trim()) {
|
||||||
post.views = (post.views || 0) + 1;
|
alert('评论内容不能为空!');
|
||||||
if (backendConnected.value) {
|
return;
|
||||||
fetch(`${API_BASE}/posts/${post.id}`).catch(() => {});
|
}
|
||||||
|
submittingComment.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/posts/${activePost.value.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newComment.value)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
newComment.value.content = '';
|
||||||
|
fetchComments(activePost.value.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('提交评论失败,请检查网络');
|
||||||
|
}
|
||||||
|
submittingComment.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文章
|
||||||
|
async function deletePost(post) {
|
||||||
|
if (!confirm(`确定要删除文章《${post.title}》吗?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/posts/${post.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
activePost.value = null;
|
||||||
|
fetchPosts();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,19 +576,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开写作框
|
|
||||||
function openPublishModal() {
|
function openPublishModal() {
|
||||||
newPost.value = {
|
newPost.value = {
|
||||||
title: '',
|
title: '',
|
||||||
category: '全栈开发 & CI/CD',
|
categoryName: '全栈开发 & CI/CD',
|
||||||
author: '旺总',
|
author: '旺总',
|
||||||
|
tags: 'SpringBoot,MySQL',
|
||||||
summary: '',
|
summary: '',
|
||||||
content: ''
|
content: ''
|
||||||
};
|
};
|
||||||
showPublishModal.value = true;
|
showPublishModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发布新文章
|
// 发布新文章至 MySQL
|
||||||
async function publishPost() {
|
async function publishPost() {
|
||||||
if (!newPost.value.title.trim() || !newPost.value.content.trim()) {
|
if (!newPost.value.title.trim() || !newPost.value.content.trim()) {
|
||||||
alert('请至少填写文章标题和文章正文内容!');
|
alert('请至少填写文章标题和文章正文内容!');
|
||||||
@@ -537,45 +596,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
publishing.value = true;
|
publishing.value = true;
|
||||||
const createdPost = {
|
try {
|
||||||
id: String(Date.now()),
|
const res = await fetch(`${API_BASE}/posts`, {
|
||||||
title: newPost.value.title,
|
method: 'POST',
|
||||||
summary: newPost.value.summary || newPost.value.content.substring(0, 100),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
content: newPost.value.content,
|
body: JSON.stringify(newPost.value)
|
||||||
author: newPost.value.author || '旺总',
|
});
|
||||||
date: new Date().toISOString().split('T')[0],
|
if (res.ok) {
|
||||||
category: newPost.value.category,
|
showPublishModal.value = false;
|
||||||
tags: [newPost.value.category.split(' ')[0]],
|
fetchPosts();
|
||||||
readTime: `${Math.max(2, Math.round(newPost.value.content.length / 300))} min`,
|
} else {
|
||||||
views: 1,
|
alert('发布失败,请稍后重试');
|
||||||
likes: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (backendConnected.value) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/posts`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(createdPost)
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const saved = await res.json();
|
|
||||||
posts.value.unshift(saved);
|
|
||||||
} else {
|
|
||||||
posts.value.unshift(createdPost);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
posts.value.unshift(createdPost);
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) {
|
||||||
posts.value.unshift(createdPost);
|
alert('发布失败,无法连接服务器');
|
||||||
}
|
}
|
||||||
|
|
||||||
publishing.value = false;
|
publishing.value = false;
|
||||||
showPublishModal.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown 渲染
|
|
||||||
function renderMarkdown(content) {
|
function renderMarkdown(content) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
if (window.marked) {
|
if (window.marked) {
|
||||||
@@ -584,7 +622,16 @@
|
|||||||
return content.replace(/\n/g, '<br>');
|
return content.replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 明暗模式切换
|
function formatDate(dt) {
|
||||||
|
if (!dt) return '刚刚';
|
||||||
|
return String(dt).split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(tags) {
|
||||||
|
if (!tags) return [];
|
||||||
|
return String(tags).split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
isDark.value = !isDark.value;
|
isDark.value = !isDark.value;
|
||||||
if (isDark.value) {
|
if (isDark.value) {
|
||||||
@@ -597,7 +644,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 初始化主题
|
|
||||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
isDark.value = true;
|
isDark.value = true;
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
@@ -606,7 +652,6 @@
|
|||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置 Marked 与代码高亮
|
|
||||||
if (window.marked && window.hljs) {
|
if (window.marked && window.hljs) {
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
highlight: function(code, lang) {
|
highlight: function(code, lang) {
|
||||||
@@ -629,16 +674,23 @@
|
|||||||
categories,
|
categories,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
searchKeyword,
|
searchKeyword,
|
||||||
filteredPosts,
|
selectCategory,
|
||||||
|
onSearchInput,
|
||||||
activePost,
|
activePost,
|
||||||
openPostDetail,
|
openPostDetail,
|
||||||
|
comments,
|
||||||
|
newComment,
|
||||||
|
submitComment,
|
||||||
|
deletePost,
|
||||||
likePost,
|
likePost,
|
||||||
showPublishModal,
|
showPublishModal,
|
||||||
newPost,
|
newPost,
|
||||||
openPublishModal,
|
openPublishModal,
|
||||||
publishPost,
|
publishPost,
|
||||||
publishing,
|
publishing,
|
||||||
renderMarkdown
|
renderMarkdown,
|
||||||
|
formatDate,
|
||||||
|
parseTags
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- 创建数据库
|
||||||
|
CREATE DATABASE IF NOT EXISTS `my_blog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE `my_blog`;
|
||||||
|
|
||||||
|
-- 1. 分类表
|
||||||
|
CREATE TABLE IF NOT EXISTS `category` (
|
||||||
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`name` VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
`description` VARCHAR(255),
|
||||||
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 2. 文章表
|
||||||
|
CREATE TABLE IF NOT EXISTS `post` (
|
||||||
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`summary` VARCHAR(500),
|
||||||
|
`content` LONGTEXT NOT NULL,
|
||||||
|
`author` VARCHAR(50) DEFAULT '旺总',
|
||||||
|
`category_name` VARCHAR(50) DEFAULT '技术杂谈',
|
||||||
|
`tags` VARCHAR(255),
|
||||||
|
`read_time` VARCHAR(20) DEFAULT '3 min',
|
||||||
|
`views` INT DEFAULT 0,
|
||||||
|
`likes` INT DEFAULT 0,
|
||||||
|
`status` VARCHAR(20) DEFAULT 'PUBLISHED',
|
||||||
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 3. 评论表
|
||||||
|
CREATE TABLE IF NOT EXISTS `comment` (
|
||||||
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`post_id` BIGINT NOT NULL,
|
||||||
|
`author` VARCHAR(50) NOT NULL,
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX `idx_post_id` (`post_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 插入默认分类
|
||||||
|
INSERT IGNORE INTO `category` (`name`, `description`) VALUES
|
||||||
|
('云原生 & Linux', '容器化编排、GCP/云服务器运维、系统调优与网络配置实战'),
|
||||||
|
('Java & Spring Boot', 'Spring Boot 3 微服务架构、JPA 数据库持久化与后端工程规范'),
|
||||||
|
('AI & 本地模型', 'Ollama 本地大模型部署、DeepSeek R1 性能压测与应用集成'),
|
||||||
|
('全栈开发 & CI/CD', '前后端分离架构、Gitea Actions 自动化流水线交付');
|
||||||
|
|
||||||
|
-- 插入预置精品文章
|
||||||
|
INSERT INTO `post` (`title`, `summary`, `content`, `author`, `category_name`, `tags`, `read_time`, `views`, `likes`, `created_at`)
|
||||||
|
SELECT
|
||||||
|
'云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel',
|
||||||
|
'本文记录了如何通过 GCP 赠金在亚洲低延迟机房部署 4核16G 高性能云服务器,并使用现代化容器面板 1Panel 进行运维管理实录。',
|
||||||
|
'# 云原生实践:在 Google Cloud 台湾节点搭建高性能服务器\n\n在挑选云服务器的过程中,综合对比了各大厂商的免费策略与性能表现,最终选择了 **Google Cloud 台湾节点 (`asia-east1`)**。\n\n## 核心硬件与网络配置\n- **规格**:`e2-standard-4`(4 vCPU,16 GB 内存)\n- **系统**:Ubuntu 22.04 LTS (x86_64)\n- **延迟**:到中国大陆仅 30~50ms,操作极为丝滑。\n\n## 为什么采用 1Panel 面板?\n相比传统臃肿的面料,1Panel 深度拥抱 **Docker 容器化**,所有应用(MySQL、OpenResty、Gitea)完全容器隔离,真正做到“环境随用随建,数据挂载持久化”。\n\n```bash\n# 快速检查 Docker 运行状态\ndocker ps --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}"\n```\n\n> 💡 **运维心得**:永远在云平台控制台统一放行防火墙端口,切勿盲目在系统内部启用 UFW,避免失联风险。',
|
||||||
|
'旺总',
|
||||||
|
'云原生 & Linux',
|
||||||
|
'GCP,Ubuntu,1Panel,Linux',
|
||||||
|
'4 min',
|
||||||
|
648,
|
||||||
|
52,
|
||||||
|
NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `post` WHERE `title` = '云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel');
|
||||||
|
|
||||||
|
INSERT INTO `post` (`title`, `summary`, `content`, `author`, `category_name`, `tags`, `read_time`, `views`, `likes`, `created_at`)
|
||||||
|
SELECT
|
||||||
|
'探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优',
|
||||||
|
'深度解析在纯 CPU 服务器上运行 DeepSeek R1 和通义千问 2.5 的真实表现,揭开 2050 Token 预填充延迟背后的机制。',
|
||||||
|
'# 探索本地大模型:从 Ollama 到 Open WebUI 性能调优实战\n\n近期 DeepSeek R1 推理思考模型火遍全球,我们在 4核16G 的服务器上进行了本地大模型的全链路压测。\n\n## 1. 架构搭建\n- **模型发动机**:`Ollama` 容器\n- **交互界面**:`Open WebUI` 容器\n- **网络通信**:通过 Docker 网关 `http://172.17.0.1:11434` 极速直连\n\n## 2. 性能排查与调优秘籍\n测试过程中遇到了“每次提问卡顿 50 秒”的问题,经过 `top` 分析和指标剖析:\n1. **解除容器 CPU 配额**:确保 4 线程全开(`eval rate` 提升至 14 tokens/s);\n2. **内存永久常驻**:注入 `OLLAMA_KEEP_ALIVE=-1`,避免 5 分钟超时后重新读盘;\n3. **清空全局默认 Prompt**:消除了默认附带的 2050 历史 Token,读题耗时从 60s 骤降至 0.2s!\n\n```bash\n# 测试模型运行性能\ndocker exec -it 1Panel-ollama-nzre ollama run qwen2.5:1.5b --verbose\n```\n\n> 纯 CPU 跑 7B 参数是物理带宽瓶颈,日常开发推荐搭配 1.5B~3B 模型,速度与逻辑达到最佳平衡!',
|
||||||
|
'旺总',
|
||||||
|
'AI & 本地模型',
|
||||||
|
'DeepSeek,Ollama,OpenWebUI,性能调优',
|
||||||
|
'6 min',
|
||||||
|
982,
|
||||||
|
88,
|
||||||
|
NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `post` WHERE `title` = '探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优');
|
||||||
|
|
||||||
|
INSERT INTO `post` (`title`, `summary`, `content`, `author`, `category_name`, `tags`, `read_time`, `views`, `likes`, `created_at`)
|
||||||
|
SELECT
|
||||||
|
'全栈工程师实战:Spring Boot 3 + MySQL 8.0 持久化与 Gitea CI/CD 自动化交付',
|
||||||
|
'从零构建工业级 Spring Boot 3 分层架构,结合 MySQL 8.0 数据库持久化存储与私有 Gitea Actions 自动化流水线。',
|
||||||
|
'# 全栈工程化实践:Spring Boot 3 + MySQL 8.0 + CI/CD 自动化\n\n现代化开发的精髓在于**“把数据交给专业数据库持久化,把重复劳动交给自动化流水线”**。\n\n## 全栈架构一览\n```text\n[开发者本地 IDEA] ──git push──▶ [私有 Gitea 仓库]\n │\n (Gitea Actions 触发流水线)\n ▼\n [Docker Compose 全栈构建]\n │\n ┌───────────────────────────┼───────────────────────────┐\n ▼ ▼ ▼\n [MySQL 8.0 容器] [Spring Boot 3 容器] [Nginx 静态反代容器]\n```\n\n## 数据持久化规范\n- 数据文件挂载至命名卷 `mysql_data`,容器重启或重建数据零丢失;\n- Spring Data JPA 实现对象关系映射,自动完成数据库结构维护;\n- 统一 RESTful API 与响应封装,全栈开发得心应手!',
|
||||||
|
'旺总',
|
||||||
|
'全栈开发 & CI/CD',
|
||||||
|
'Spring Boot,MySQL,JPA,CI/CD',
|
||||||
|
'5 min',
|
||||||
|
756,
|
||||||
|
64,
|
||||||
|
NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `post` WHERE `title` = '全栈工程师实战:Spring Boot 3 + MySQL 8.0 持久化与 Gitea CI/CD 自动化交付');
|
||||||
Reference in New Issue
Block a user