AI智能博客平台实战:RAG增强写作、多Agent协作与语义检索的Vue3 + Spring AI全栈实现

AI智能博客平台实战:RAG增强写作、多Agent协作与语义检索的Vue3 + Spring AI全栈实现

本文将带你从零构建一个完整的 AI 智能博客平台,前端使用 Vue3 + TypeScript + Element Plus,后端使用 Spring Boot + Spring AI,实现 AI 辅助写作、智能标签生成、内容推荐等核心功能。


博客:

https://blog.csdn.net/badao_liumang_qizhi

一、项目架构设计

1.1 整体架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                      前端 (Vue3 + Vite)                      │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ 博客列表  │  │ 文章编辑  │  │ AI 助手  │  │ 个人中心  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└─────────────────────────────────────────────────────────────┘
                              │ HTTP / SSE
┌─────────────────────────────────────────────────────────────┐
│                   后端 (Spring Boot 3.x)                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ 博客服务  │  │ AI 服务   │  │ 用户服务  │  │ 搜索服务  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              Spring AI (DashScope)                   │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────────┐
│                    数据层                                    │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                  │
│  │  MySQL   │  │  Redis   │  │  Milvus  │                  │
│  └──────────┘  └──────────┘  └──────────┘                  │
└─────────────────────────────────────────────────────────────┘

1.2 技术栈选型

层级 技术选型 版本
前端框架 Vue3 + TypeScript 3.4+
UI 组件库 Element Plus 2.5+
构建工具 Vite 5.0+
状态管理 Pinia 2.1+
后端框架 Spring Boot 3.2+
AI 框架 Spring AI 1.0+
模型服务 DashScope (通义千问) -
数据库 MySQL 8.0+
缓存 Redis 7.0+
向量库 Milvus 2.3+

1.3 核心功能模块与数据流

功能模块 核心能力 关键技术
AI 辅助写作 摘要生成、标签推荐、文章润色、写作灵感 Spring AI ChatClient
语义搜索 基于向量的文章检索 Milvus + Embedding
流式对话 AI 写作助手实时响应 SSE + Flux
浏览量统计 Redis 去重计数 Redis SetIfAbsent
热门文章 基于浏览量的排序 Redis ZSet
文章向量化 内容语义索引 EmbeddingClient + Milvus

核心数据流:用户写作 → 保存文章 → AI 自动生成摘要和标签 → 文章向量化存入 Milvus → 用户搜索时进行语义匹配 → 返回相关文章。

二、后端实现

2.1 项目依赖配置

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>ai-blog-backend</artifactId>
    <version>1.0.0</version>
    <name>AI Blog Backend</name>
    
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-M4</spring-ai.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <!-- Spring AI -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        
        <!-- MySQL -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.33</version>
        </dependency>
        
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- Milvus -->
        <dependency>
            <groupId>io.milvus</groupId>
            <artifactId>milvus-sdk-java</artifactId>
            <version>2.3.4</version>
        </dependency>
    </dependencies>
</project>

2.2 配置文件

yaml 复制代码
# application.yml
server:
  port: 8080

spring:
  application:
    name: ai-blog-backend
  
  datasource:
    url: jdbc:mysql://localhost:3306/ai_blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
        format_sql: true
  
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      timeout: 5000ms

# Spring AI 配置 (DashScope)
spring:
  ai:
    openai:
      api-key: ${DASHSCOPE_API_KEY}
      base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
      chat:
        options:
          model: qwen-max
          temperature: 0.7
          max-tokens: 4096

# Milvus 配置
milvus:
  host: localhost
  port: 19530
  collection: blog_vectors

# 自定义配置
blog:
  ai:
    summary-max-length: 200
    tag-max-count: 5
    recommend-count: 6

2.3 实体类定义

java 复制代码
package com.example.aiblog.entity;

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;
import java.util.List;

@Data
@Entity
@Table(name = "blog_post")
public class BlogPost {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 200)
    private String title;
    
    @Column(columnDefinition = "LONGTEXT")
    private String content;
    
    @Column(length = 500)
    private String summary;
    
    @Column(length = 500)
    private String coverImage;
    
    @ElementCollection
    @CollectionTable(name = "blog_post_tags", joinColumns = @JoinColumn(name = "post_id"))
    @Column(name = "tag")
    private List<String> tags;
    
    @Column(nullable = false)
    private Long authorId;
    
    @Column(length = 100)
    private String authorName;
    
    @Column(nullable = false)
    private Integer viewCount = 0;
    
    @Column(nullable = false)
    private Integer likeCount = 0;
    
    @Column(nullable = false)
    private Integer status = 0; // 0-草稿 1-已发布 2-归档
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    @UpdateTimestamp
    private LocalDateTime updatedAt;
    
    private LocalDateTime publishedAt;
}
java 复制代码
package com.example.aiblog.entity;

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Data
@Entity
@Table(name = "blog_ai_conversation")
public class BlogAiConversation {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private Long userId;
    
    @Column(nullable = false)
    private Long postId;
    
    @Column(nullable = false, length = 20)
    private String role; // user / assistant
    
    @Column(columnDefinition = "TEXT")
    private String content;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
}

2.4 AI 服务层

java 复制代码
package com.example.aiblog.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Slf4j
@Service
@RequiredArgsConstructor
public class AiBlogService {
    
    private final ChatClient.Builder chatClientBuilder;
    
    private static final String SYSTEM_PROMPT = """
            你是一个专业的博客写作助手,擅长帮助用户:
            1. 优化文章结构和表达
            2. 生成文章摘要
            3. 推荐合适的标签
            4. 提供写作灵感和建议
            5. 润色和改进文字表达
            
            请始终用中文回答,保持专业友好的语气。
            """;
    
    /**
     * 生成文章摘要
     */
    public String generateSummary(String content) {
        String prompt = String.format(
            "请为以下文章生成一个简洁的摘要(不超过150字):\n\n%s",
            content.substring(0, Math.min(content.length(), 3000))
        );
        
        return chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
    }
    
    /**
     * 生成推荐标签
     */
    public List<String> generateTags(String title, String content) {
        String prompt = String.format(
            "请根据以下文章标题和内容,推荐%d个最相关的标签(用逗号分隔):\n\n标题:%s\n\n内容:%s",
            5, title, content.substring(0, Math.min(content.length(), 2000))
        );
        
        String response = chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
        
        return parseTags(response);
    }
    
    /**
     * 流式对话 - AI 写作助手
     */
    public Flux<String> streamChat(Long userId, Long postId, String userMessage, 
                                    List<BlogAiConversation> history) {
        List<Message> messages = new ArrayList<>();
        messages.add(new SystemMessage(SYSTEM_PROMPT));
        
        // 添加历史对话
        for (BlogAiConversation conv : history) {
            if ("user".equals(conv.getRole())) {
                messages.add(new UserMessage(conv.getContent()));
            } else {
                messages.add(new AssistantMessage(conv.getContent()));
            }
        }
        
        // 添加当前用户消息
        messages.add(new UserMessage(userMessage));
        
        Prompt prompt = new Prompt(messages, OpenAiChatOptions.builder()
                .withModel("qwen-max")
                .withTemperature(0.7)
                .withMaxTokens(2048)
                .build());
        
        return chatClientBuilder.build()
                .prompt(prompt)
                .stream()
                .content();
    }
    
    /**
     * AI 文章润色
     */
    public String polishContent(String content, String style) {
        String prompt = String.format(
            "请将以下文章润色为%s风格,保持原意不变,优化表达和结构:\n\n%s",
            style, content
        );
        
        return chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
    }
    
    /**
     * 生成写作灵感
     */
    public List<String> generateWritingIdeas(String topic) {
        String prompt = String.format(
            "请以'%s'为主题,提供5个有创意的写作角度或子话题,每个一行:",
            topic
        );
        
        String response = chatClientBuilder.build()
                .prompt(prompt)
                .call()
                .content();
        
        return List.of(response.split("\\n")).stream()
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .limit(5)
                .toList();
    }
    
    private List<String> parseTags(String response) {
        Pattern pattern = Pattern.compile("[\"']([^\"']+)[\"']|([\\u4e00-\\u9fa5a-zA-Z0-9]+)");
        Matcher matcher = pattern.matcher(response);
        List<String> tags = new ArrayList<>();
        
        while (matcher.find() && tags.size() < 5) {
            String tag = matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
            if (tag != null && !tag.isBlank()) {
                tags.add(tag.trim());
            }
        }
        
        return tags;
    }
}

代码解析 :generateSummary 和 generateTags 是两个核心 AI 辅助功能。generateSummary 截取内容的前 3000 字符(避免超出模型上下文窗口),让模型生成不超过 150 字的摘要。generateTags 要求模型返回 5 个最相关的标签,然后通过正则表达式解析模型返回的文本。parseTags 的正则同时匹配带引号的标签和中文/英文标签,确保解析的健壮性。

生活类比:AI 服务层就像一位全能编辑助理。 你写完稿子交给它,它可以帮你写摘要(提炼核心)、贴标签(分类归档)、润色文章(改错别字和语病)、想选题(头脑风暴)。每个功能都是一个独立的"技能",通过 ChatClient 调用 LLM 来实现。

2.5 博客服务层

java 复制代码
package com.example.aiblog.service;

import com.example.aiblog.entity.BlogPost;
import com.example.aiblog.repository.BlogPostRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class BlogService {
    
    private final BlogPostRepository blogPostRepository;
    private final AiBlogService aiBlogService;
    private final RedisTemplate<String, Object> redisTemplate;
    private final VectorService vectorService;
    
    private static final String VIEW_COUNT_KEY = "blog:view:";
    private static final String HOT_POSTS_KEY = "blog:hot_posts";
    
    /**
     * 创建文章(带 AI 辅助)
     */
    @Transactional
    public BlogPost createPost(BlogPost post, boolean useAiAssist) {
        if (useAiAssist && (post.getSummary() == null || post.getSummary().isBlank())) {
            // 自动生成摘要
            String summary = aiBlogService.generateSummary(post.getContent());
            post.setSummary(summary);
        }
        
        if (useAiAssist && (post.getTags() == null || post.getTags().isEmpty())) {
            // 自动生成标签
            List<String> tags = aiBlogService.generateTags(post.getTitle(), post.getContent());
            post.setTags(tags);
        }
        
        post.setStatus(1);
        post.setPublishedAt(LocalDateTime.now());
        post.setViewCount(0);
        post.setLikeCount(0);
        
        BlogPost saved = blogPostRepository.save(post);
        
        // 存入向量数据库用于语义搜索
        vectorService.insertPostVector(saved);
        
        // 清除热门文章缓存
        redisTemplate.delete(HOT_POSTS_KEY);
        
        return saved;
    }
    
    /**
     * 获取文章详情(带浏览量统计)
     */
    public BlogPost getPost(Long id, String viewerIp) {
        BlogPost post = blogPostRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("文章不存在"));
        
        // 使用 Redis 统计浏览量(同一IP 24小时内只算一次)
        String viewKey = VIEW_COUNT_KEY + id + ":" + viewerIp;
        Boolean notViewed = redisTemplate.opsForValue()
                .setIfAbsent(viewKey, "1", Duration.ofHours(24));
        
        if (Boolean.TRUE.equals(notViewed)) {
            post.setViewCount(post.getViewCount() + 1);
            blogPostRepository.save(post);
            
            // 更新热门文章有序集合
            redisTemplate.opsForZSet().incrementScore(HOT_POSTS_KEY, id.toString(), 1);
        }
        
        return post;
    }
    
    /**
     * 获取热门文章
     */
    @SuppressWarnings("unchecked")
    public List<BlogPost> getHotPosts(int limit) {
        // 先从缓存获取
        List<BlogPost> cached = (List<BlogPost>) redisTemplate.opsForValue().get(HOT_POSTS_KEY + ":list");
        if (cached != null) {
            return cached;
        }
        
        // 从数据库获取
        List<BlogPost> posts = blogPostRepository.findByStatusOrderByViewCountDesc(
                1, PageRequest.of(0, limit)).getContent();
        
        // 缓存10分钟
        redisTemplate.opsForValue().set(HOT_POSTS_KEY + ":list", posts, 10, TimeUnit.MINUTES);
        
        return posts;
    }
    
    /**
     * 语义搜索
     */
    public List<BlogPost> semanticSearch(String query, int limit) {
        List<Long> ids = vectorService.searchSimilarPosts(query, limit);
        return blogPostRepository.findAllById(ids);
    }
    
    /**
     * 分页获取文章列表
     */
    public Page<BlogPost> getPostList(int page, int size, String tag, String keyword) {
        if (tag != null && !tag.isBlank()) {
            return blogPostRepository.findByTagsContainingAndStatus(
                    tag, 1, PageRequest.of(page, size));
        }
        if (keyword != null && !keyword.isBlank()) {
            return blogPostRepository.findByTitleContainingAndStatus(
                    keyword, 1, PageRequest.of(page, size));
        }
        return blogPostRepository.findByStatusOrderByPublishedAtDesc(
                1, PageRequest.of(page, size));
    }
}

2.6 向量服务(语义搜索)

java 复制代码
package com.example.aiblog.service;

import com.example.aiblog.entity.BlogPost;
import io.milvus.client.MilvusServiceClient;
import io.milvus.param.ConnectParam;
import io.milvus.param.collection.CollectionParam;
import io.milvus.param.dml.InsertParam;
import io.milvus.param.dml.SearchParam;
import io.milvus.response.SearchResultsWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class VectorService {
    
    private final EmbeddingClient embeddingClient;
    
    @Value("${milvus.host}")
    private String milvusHost;
    
    @Value("${milvus.port}")
    private Integer milvusPort;
    
    @Value("${milvus.collection}")
    private String collectionName;
    
    private MilvusServiceClient milvusClient;
    
    @PostConstruct
    public void init() {
        milvusClient = new MilvusServiceClient(
                ConnectParam.newBuilder()
                        .withHost(milvusHost)
                        .withPort(milvusPort)
                        .build()
        );
        createCollectionIfNotExists();
    }
    
    private void createCollectionIfNotExists() {
        try {
            CollectionParam collectionParam = CollectionParam.newBuilder()
                    .withCollectionName(collectionName)
                    .withDimension(1536) // text-embedding-3-small 维度
                    .withMetricType("COSINE")
                    .build();
            
            try {
                milvusClient.createCollection(collectionParam);
                log.info("创建 Milvus collection: {}", collectionName);
            } catch (Exception e) {
                log.info("Collection 已存在: {}", collectionName);
            }
        } catch (Exception e) {
            log.error("初始化 Milvus 失败", e);
        }
    }
    
    /**
     * 插入文章向量
     */
    public void insertPostVector(BlogPost post) {
        try {
            // 生成文本嵌入
            String textToEmbed = post.getTitle() + " " + 
                    (post.getSummary() != null ? post.getSummary() : "");
            float[] embedding = embeddingClient.embed(textToEmbed);
            
            // 准备数据
            List<Long> ids = List.of(post.getId());
            List<String> titles = List.of(post.getTitle());
            List<List<Float>> embeddings = List.of(
                    Arrays.stream(embedding).boxed().collect(Collectors.toList())
            );
            
            // 插入 Milvus
            InsertParam insertParam = InsertParam.newBuilder()
                    .withCollectionName(collectionName)
                    .withFields(Arrays.asList(
                            new InsertParam.Field("id", ids),
                            new InsertParam.Field("title", titles),
                            new InsertParam.Field("embedding", embeddings)
                    ))
                    .build();
            
            milvusClient.insert(insertParam);
            milvusClient.flush(Collections.singletonList(collectionName));
            
        } catch (Exception e) {
            log.error("插入向量失败: {}", post.getId(), e);
        }
    }
    
    /**
     * 语义搜索相似文章
     */
    public List<Long> searchSimilarPosts(String query, int topK) {
        try {
            // 生成查询向量
            float[] queryEmbedding = embeddingClient.embed(query);
            List<List<Float>> queryVectors = List.of(
                    Arrays.stream(queryEmbedding).boxed().collect(Collectors.toList())
            );
            
            // 搜索
            SearchParam searchParam = SearchParam.newBuilder()
                    .withCollectionName(collectionName)
                    .withVector(queryVectors)
                    .withTopK(topK)
                    .withMetricType("COSINE")
                    .withParams("{\"nprobe\": 10}")
                    .withOutFields(List.of("id"))
                    .build();
            
            SearchResultsWrapper results = new SearchResultsWrapper(
                    milvusClient.search(searchParam).getData()
            );
            
            return results.getIDScore(0).stream()
                    .map(idScore -> idScore.getLongID())
                    .collect(Collectors.toList());
            
        } catch (Exception e) {
            log.error("语义搜索失败", e);
            return Collections.emptyList();
        }
    }
}

代码解析 :向量服务是语义搜索的核心。insertPostVector 将文章标题和摘要拼接后通过 EmbeddingClient 生成向量(默认维度 1536),存入 Milvus 向量数据库。searchSimilarPosts 将用户查询同样转为向量,通过余弦相似度搜索最相似的 TopK 篇文章。

生活类比:向量搜索就像"按意思找文章",而非"按关键词找文章"。 传统搜索要求文章标题必须包含"Java 多线程"才能匹配;向量搜索则能理解"并发编程"和"多线程"是同一个意思------即使文章标题里没有"并发"二字,也能被检索出来。

2.7 REST 控制器

java 复制代码
package com.example.aiblog.controller;

import com.example.aiblog.entity.BlogAiConversation;
import com.example.aiblog.entity.BlogPost;
import com.example.aiblog.service.AiBlogService;
import com.example.aiblog.service.BlogService;
import com.example.aiblog.service.ConversationService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/blog")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class BlogController {
    
    private final BlogService blogService;
    private final AiBlogService aiBlogService;
    private final ConversationService conversationService;
    
    /**
     * 创建文章
     */
    @PostMapping("/posts")
    public BlogPost createPost(@RequestBody CreatePostRequest request) {
        BlogPost post = new BlogPost();
        post.setTitle(request.getTitle());
        post.setContent(request.getContent());
        post.setAuthorId(request.getAuthorId());
        post.setAuthorName(request.getAuthorName());
        post.setCoverImage(request.getCoverImage());
        
        return blogService.createPost(post, request.isUseAiAssist());
    }
    
    /**
     * 获取文章详情
     */
    @GetMapping("/posts/{id}")
    public BlogPost getPost(@PathVariable Long id, 
                            @RequestHeader(value = "X-Forwarded-For", required = false) String ip) {
        return blogService.getPost(id, ip != null ? ip : "unknown");
    }
    
    /**
     * 文章列表
     */
    @GetMapping("/posts")
    public Page<BlogPost> getPostList(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(required = false) String tag,
            @RequestParam(required = false) String keyword) {
        return blogService.getPostList(page, size, tag, keyword);
    }
    
    /**
     * 热门文章
     */
    @GetMapping("/posts/hot")
    public List<BlogPost> getHotPosts(@RequestParam(defaultValue = "10") int limit) {
        return blogService.getHotPosts(limit);
    }
    
    /**
     * 语义搜索
     */
    @GetMapping("/search")
    public List<BlogPost> search(@RequestParam String q,
                                  @RequestParam(defaultValue = "10") int limit) {
        return blogService.semanticSearch(q, limit);
    }
    
    /**
     * AI 写作助手 - 流式响应
     */
    @GetMapping(value = "/ai/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> aiChat(
            @RequestParam Long userId,
            @RequestParam Long postId,
            @RequestParam String message) {
        
        List<BlogAiConversation> history = conversationService
                .getRecentConversations(userId, postId, 10);
        
        return aiBlogService.streamChat(userId, postId, message, history)
                .doOnNext(content -> 
                    conversationService.saveConversation(userId, postId, "user", message))
                .doOnComplete(() -> 
                    conversationService.saveConversation(userId, postId, "assistant", "[完成]"));
    }
    
    /**
     * AI 生成摘要
     */
    @PostMapping("/ai/summary")
    public Map<String, String> generateSummary(@RequestBody Map<String, String> request) {
        String summary = aiBlogService.generateSummary(request.get("content"));
        return Map.of("summary", summary);
    }
    
    /**
     * AI 生成标签
     */
    @PostMapping("/ai/tags")
    public Map<String, List<String>> generateTags(@RequestBody Map<String, String> request) {
        List<String> tags = aiBlogService.generateTags(
                request.get("title"), request.get("content"));
        return Map.of("tags", tags);
    }
    
    /**
     * AI 润色文章
     */
    @PostMapping("/ai/polish")
    public Map<String, String> polishContent(@RequestBody PolishRequest request) {
        String polished = aiBlogService.polishContent(
                request.getContent(), request.getStyle());
        return Map.of("content", polished);
    }
    
    /**
     * 获取写作灵感
     */
    @GetMapping("/ai/ideas")
    public Map<String, List<String>> getIdeas(@RequestParam String topic) {
        List<String> ideas = aiBlogService.generateWritingIdeas(topic);
        return Map.of("ideas", ideas);
    }
}

// DTO 类
@Data
class CreatePostRequest {
    private String title;
    private String content;
    private Long authorId;
    private String authorName;
    private String coverImage;
    private boolean useAiAssist = true;
}

@Data
class PolishRequest {
    private String content;
    private String style = "专业";
}

三、前端实现

3.1 项目初始化

bash 复制代码
npm create vite@latest ai-blog-frontend -- --template vue-ts
cd ai-blog-frontend
npm install
npm install element-plus @element-plus/icons-vue
npm install pinia vue-router axios
npm install @vueuse/core marked highlight.js

3.2 路由配置

typescript 复制代码
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/post/:id',
    name: 'PostDetail',
    component: () => import('@/views/PostDetail.vue')
  },
  {
    path: '/edit',
    name: 'PostEdit',
    component: () => import('@/views/PostEdit.vue')
  },
  {
    path: '/edit/:id',
    name: 'PostEditWithId',
    component: () => import('@/views/PostEdit.vue')
  },
  {
    path: '/search',
    name: 'Search',
    component: () => import('@/views/Search.vue')
  },
  {
    path: '/profile',
    name: 'Profile',
    component: () => import('@/views/Profile.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

3.3 API 封装

typescript 复制代码
// src/api/blog.ts
import axios from 'axios'
import type { BlogPost, PageResponse, AiChatMessage } from '@/types'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
  timeout: 30000
})

// 请求拦截器
api.interceptors.request.use(config => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

export const blogApi = {
  // 获取文章列表
  getPosts(params: {
    page?: number
    size?: number
    tag?: string
    keyword?: string
  }) {
    return api.get<PageResponse<BlogPost>>('/blog/posts', { params })
  },

  // 获取文章详情
  getPost(id: number) {
    return api.get<BlogPost>(`/blog/posts/${id}`)
  },

  // 创建文章
  createPost(data: Partial<BlogPost> & { useAiAssist?: boolean }) {
    return api.post<BlogPost>('/blog/posts', data)
  },

  // 更新文章
  updatePost(id: number, data: Partial<BlogPost>) {
    return api.put<BlogPost>(`/blog/posts/${id}`, data)
  },

  // 删除文章
  deletePost(id: number) {
    return api.delete(`/blog/posts/${id}`)
  },

  // 热门文章
  getHotPosts(limit = 10) {
    return api.get<BlogPost[]>('/blog/posts/hot', { params: { limit } })
  },

  // 语义搜索
  search(query: string, limit = 10) {
    return api.get<BlogPost[]>('/blog/search', { params: { q: query, limit } })
  },

  // AI 生成摘要
  generateSummary(content: string) {
    return api.post<{ summary: string }>('/blog/ai/summary', { content })
  },

  // AI 生成标签
  generateTags(title: string, content: string) {
    return api.post<{ tags: string[] }>('/blog/ai/tags', { title, content })
  },

  // AI 润色
  polishContent(content: string, style: string) {
    return api.post<{ content: string }>('/blog/ai/polish', { content, style })
  },

  // 获取写作灵感
  getIdeas(topic: string) {
    return api.get<{ ideas: string[] }>('/blog/ai/ideas', { params: { topic } })
  }
}

// SSE 流式请求
export function createAiChatStream(
  userId: number,
  postId: number,
  message: string,
  onMessage: (content: string) => void,
  onError?: (error: Error) => void
): EventSource {
  const url = `${import.meta.env.VITE_API_BASE_URL}/blog/ai/chat?userId=${userId}&postId=${postId}&message=${encodeURIComponent(message)}`
  const eventSource = new EventSource(url)

  eventSource.onmessage = (event) => {
    onMessage(event.data)
  }

  eventSource.onerror = (error) => {
    onError?.(new Error('连接中断'))
    eventSource.close()
  }

  return eventSource
}

3.4 类型定义

typescript 复制代码
// src/types/index.ts
export interface BlogPost {
  id: number
  title: string
  content: string
  summary: string
  coverImage?: string
  tags: string[]
  authorId: number
  authorName: string
  viewCount: number
  likeCount: number
  status: number
  createdAt: string
  updatedAt: string
  publishedAt?: string
}

export interface PageResponse<T> {
  content: T[]
  totalElements: number
  totalPages: number
  size: number
  number: number
}

export interface AiChatMessage {
  id: number
  role: 'user' | 'assistant'
  content: string
  createdAt: string
}

export interface UserInfo {
  id: number
  username: string
  nickname: string
  avatar?: string
  email: string
}

3.5 文章编辑组件(带 AI 助手)

vue 复制代码
<!-- src/views/PostEdit.vue -->
<template>
  <div class="post-edit-container">
    <div class="edit-layout">
      <!-- 左侧编辑区 -->
      <div class="editor-section">
        <el-input
          v-model="post.title"
          placeholder="请输入文章标题..."
          class="title-input"
          size="large"
        />
        
        <div class="editor-toolbar">
          <el-button-group>
            <el-button @click="handleBold" :icon="Bold" />
            <el-button @click="handleItalic" :icon="Italic" />
            <el-button @click="handleHeading" :icon="Heading" />
            <el-button @click="handleLink" :icon="Link" />
            <el-button @click="handleImage" :icon="Picture" />
            <el-button @click="handleCode" :icon="Code" />
          </el-button-group>
          
          <div class="ai-actions">
            <el-button type="primary" @click="handleAiPolish" :loading="aiLoading">
              <el-icon><Magic /></el-icon>
              AI 润色
            </el-button>
            <el-button @click="handleAiSummary">
              <el-icon><Document /></el-icon>
              生成摘要
            </el-button>
            <el-button @click="handleAiTags">
              <el-icon><PriceTag /></el-icon>
              智能标签
            </el-button>
          </div>
        </div>

        <el-input
          v-model="post.content"
          type="textarea"
          :rows="25"
          placeholder="开始写作..."
          class="content-editor"
          @input="handleContentChange"
        />

        <div class="tags-section">
          <el-form-item label="标签">
            <el-select
              v-model="post.tags"
              multiple
              filterable
              allow-create
              placeholder="选择或输入标签"
              style="width: 100%"
            >
              <el-option
                v-for="tag in suggestedTags"
                :key="tag"
                :label="tag"
                :value="tag"
              />
            </el-select>
          </el-form-item>
        </div>

        <div class="summary-section">
          <el-form-item label="摘要">
            <el-input
              v-model="post.summary"
              type="textarea"
              :rows="3"
              placeholder="文章摘要,可点击上方按钮自动生成"
            />
          </el-form-item>
        </div>

        <div class="action-buttons">
          <el-button @click="handleSaveDraft">保存草稿</el-button>
          <el-button type="primary" @click="handlePublish">发布文章</el-button>
        </div>
      </div>

      <!-- 右侧 AI 助手 -->
      <div class="ai-assistant">
        <AiChatPanel
          :post-id="postId"
          :user-id="currentUser.id"
          @insert-content="handleInsertContent"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Magic, Document, PriceTag, Bold, Italic, Heading, Link, Picture } from '@element-plus/icons-vue'
import { blogApi } from '@/api/blog'
import { useUserStore } from '@/stores/user'
import AiChatPanel from '@/components/AiChatPanel.vue'

const route = useRoute()
const router = useRouter()
const userStore = useUserStore()

const postId = computed(() => route.params.id ? Number(route.params.id) : null)
const currentUser = computed(() => userStore.userInfo)

const post = reactive({
  title: '',
  content: '',
  summary: '',
  tags: [] as string[],
  coverImage: '',
  authorId: currentUser.value?.id || 0,
  authorName: currentUser.value?.nickname || ''
})

const aiLoading = ref(false)
const suggestedTags = ref<string[]>([])

// 加载文章数据
onMounted(async () => {
  if (postId.value) {
    try {
      const { data } = await blogApi.getPost(postId.value)
      Object.assign(post, data)
    } catch (error) {
      ElMessage.error('加载文章失败')
    }
  }
})

// AI 润色
const handleAiPolish = async () => {
  if (!post.content.trim()) {
    ElMessage.warning('请先输入文章内容')
    return
  }
  aiLoading.value = true
  try {
    const { data } = await blogApi.polishContent(post.content, '专业')
    post.content = data.content
    ElMessage.success('润色完成')
  } catch (error) {
    ElMessage.error('AI 润色失败')
  } finally {
    aiLoading.value = false
  }
}

// 生成摘要
const handleAiSummary = async () => {
  if (!post.content.trim()) {
    ElMessage.warning('请先输入文章内容')
    return
  }
  try {
    const { data } = await blogApi.generateSummary(post.content)
    post.summary = data.summary
    ElMessage.success('摘要生成成功')
  } catch (error) {
    ElMessage.error('生成摘要失败')
  }
}

// 智能标签
const handleAiTags = async () => {
  if (!post.title.trim() || !post.content.trim()) {
    ElMessage.warning('请先输入标题和内容')
    return
  }
  try {
    const { data } = await blogApi.generateTags(post.title, post.content)
    post.tags = [...new Set([...post.tags, ...data.tags])]
    ElMessage.success('标签生成成功')
  } catch (error) {
    ElMessage.error('生成标签失败')
  }
}

// 保存草稿
const handleSaveDraft = async () => {
  try {
    if (postId.value) {
      await blogApi.updatePost(postId.value, { ...post, status: 0 })
    } else {
      await blogApi.createPost({ ...post, status: 0 })
    }
    ElMessage.success('草稿保存成功')
  } catch (error) {
    ElMessage.error('保存失败')
  }
}

// 发布文章
const handlePublish = async () => {
  if (!post.title.trim() || !post.content.trim()) {
    ElMessage.warning('标题和内容不能为空')
    return
  }
  try {
    if (postId.value) {
      await blogApi.updatePost(postId.value, { ...post, status: 1 })
    } else {
      await blogApi.createPost({ ...post, useAiAssist: true })
    }
    ElMessage.success('发布成功')
    router.push('/')
  } catch (error) {
    ElMessage.error('发布失败')
  }
}

// 插入 AI 生成的内容
const handleInsertContent = (content: string) => {
  post.content += '\n' + content
}

// 编辑器工具栏方法
const handleBold = () => insertMarkdown('**', '**')
const handleItalic = () => insertMarkdown('*', '*')
const handleHeading = () => insertMarkdown('## ', '')
const handleLink = () => insertMarkdown('[', '](url)')
const handleImage = () => insertMarkdown('![alt](', ')')

const insertMarkdown = (prefix: string, suffix: string) => {
  // 简化实现,实际应使用专业编辑器
  post.content += `${prefix}文本${suffix}`
}

const handleContentChange = () => {
  // 防抖处理,可用于自动保存
}
</script>

<style scoped>
.post-edit-container {
  max-width: 1400px;
  margin: 0 auto;
  padding: 20px;
}

.edit-layout {
  display: grid;
  grid-template-columns: 1fr 350px;
  gap: 20px;
}

.editor-section {
  background: #fff;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.title-input {
  margin-bottom: 16px;
  font-size: 24px;
}

.title-input :deep(.el-input__inner) {
  font-size: 24px;
  font-weight: bold;
  border: none;
  padding-left: 0;
}

.editor-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #eee;
}

.ai-actions {
  display: flex;
  gap: 8px;
}

.content-editor :deep(.el-textarea__inner) {
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
  font-size: 16px;
  line-height: 1.8;
}

.tags-section,
.summary-section {
  margin-top: 20px;
}

.action-buttons {
  margin-top: 24px;
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

.ai-assistant {
  position: sticky;
  top: 20px;
}
</style>

3.6 AI 聊天面板组件

vue 复制代码
<!-- src/components/AiChatPanel.vue -->
<template>
  <div class="ai-chat-panel">
    <div class="chat-header">
      <el-icon><ChatDotRound /></el-icon>
      <span>AI 写作助手</span>
      <el-button 
        :icon="Delete" 
        circle 
        size="small" 
        @click="clearHistory"
      />
    </div>

    <div class="chat-messages" ref="messagesContainer">
      <div 
        v-for="msg in messages" 
        :key="msg.id"
        :class="['message', msg.role]"
      >
        <div class="message-avatar">
          <el-avatar :size="32">
            {{ msg.role === 'user' ? '我' : 'AI' }}
          </el-avatar>
        </div>
        <div class="message-content">
          <div v-if="msg.role === 'assistant'" class="ai-response">
            <div v-html="formatMessage(msg.content)" />
            <div v-if="msg.role === 'assistant' && msg.content" class="message-actions">
              <el-button size="small" @click="insertToEditor(msg.content)">
                插入文章
              </el-button>
              <el-button size="small" @click="copyContent(msg.content)">
                复制
              </el-button>
            </div>
          </div>
          <div v-else>{{ msg.content }}</div>
        </div>
      </div>

      <div v-if="isLoading" class="message assistant">
        <div class="message-avatar">
          <el-avatar :size="32">AI</el-avatar>
        </div>
        <div class="message-content">
          <div class="typing-indicator">
            <span></span>
            <span></span>
            <span></span>
          </div>
        </div>
      </div>
    </div>

    <div class="quick-actions">
      <el-button 
        v-for="action in quickActions" 
        :key="action.label"
        size="small"
        @click="sendQuickAction(action.prompt)"
      >
        {{ action.label }}
      </el-button>
    </div>

    <div class="chat-input">
      <el-input
        v-model="inputMessage"
        type="textarea"
        :rows="3"
        placeholder="向 AI 助手提问..."
        @keydown.enter.exact.prevent="sendMessage"
      />
      <el-button 
        type="primary" 
        @click="sendMessage"
        :loading="isLoading"
        :disabled="!inputMessage.trim()"
      >
        <el-icon><Promotion /></el-icon>
        发送
      </el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ChatDotRound, Delete, Promotion } from '@element-plus/icons-vue'
import { marked } from 'marked'
import { createAiChatStream } from '@/api/blog'

const props = defineProps<{
  postId: number
  userId: number
}>()

const emit = defineEmits<{
  (e: 'insertContent', content: string): void
}>()

interface ChatMessage {
  id: number
  role: 'user' | 'assistant'
  content: string
}

const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement>()
let eventSource: EventSource | null = null

const quickActions = [
  { label: '优化开头', prompt: '请帮我优化这段文章的开头,使其更吸引人:' },
  { label: '扩展内容', prompt: '请帮我扩展以下内容,增加更多细节:' },
  { label: '总结要点', prompt: '请帮我总结这篇文章的核心要点:' },
  { label: '检查逻辑', prompt: '请帮我检查这段内容的逻辑是否通顺:' }
]

const sendMessage = () => {
  if (!inputMessage.value.trim() || isLoading.value) return

  const userMsg: ChatMessage = {
    id: Date.now(),
    role: 'user',
    content: inputMessage.value
  }
  messages.value.push(userMsg)

  // 创建 AI 回复占位
  const aiMsg: ChatMessage = {
    id: Date.now() + 1,
    role: 'assistant',
    content: ''
  }
  messages.value.push(aiMsg)

  isLoading.value = true
  scrollToBottom()

  // 关闭之前的连接
  eventSource?.close()

  // 建立 SSE 连接
  eventSource = createAiChatStream(
    props.userId,
    props.postId,
    inputMessage.value,
    (content) => {
      aiMsg.content += content
      scrollToBottom()
    },
    (error) => {
      ElMessage.error('连接出错,请重试')
      isLoading.value = false
    }
  )

  eventSource.onerror = () => {
    isLoading.value = false
    eventSource?.close()
  }

  inputMessage.value = ''
}

const sendQuickAction = (prompt: string) => {
  inputMessage.value = prompt
  sendMessage()
}

const clearHistory = () => {
  messages.value = []
  ElMessage.success('对话已清空')
}

const insertToEditor = (content: string) => {
  emit('insertContent', content)
  ElMessage.success('已插入到编辑器')
}

const copyContent = (content: string) => {
  navigator.clipboard.writeText(content)
  ElMessage.success('已复制到剪贴板')
}

const formatMessage = (content: string) => {
  return marked(content)
}

const scrollToBottom = () => {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
    }
  })
}

onUnmounted(() => {
  eventSource?.close()
})
</script>

<style scoped>
.ai-chat-panel {
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  height: calc(100vh - 100px);
}

.chat-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 16px;
  border-bottom: 1px solid #eee;
  font-weight: bold;
}

.chat-header span {
  flex: 1;
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.message {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
}

.message.user {
  flex-direction: row-reverse;
}

.message.user .message-content {
  background: #409eff;
  color: #fff;
  border-radius: 12px 12px 4px 12px;
}

.message.assistant .message-content {
  background: #f5f7fa;
  border-radius: 12px 12px 12px 4px;
}

.message-content {
  padding: 12px 16px;
  max-width: 80%;
  line-height: 1.6;
}

.message-actions {
  margin-top: 8px;
  display: flex;
  gap: 8px;
}

.quick-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 12px 16px;
  border-top: 1px solid #eee;
}

.chat-input {
  padding: 16px;
  border-top: 1px solid #eee;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.typing-indicator {
  display: flex;
  gap: 4px;
}

.typing-indicator span {
  width: 8px;
  height: 8px;
  background: #409eff;
  border-radius: 50%;
  animation: typing 1.4s infinite;
}

.typing-indicator span:nth-child(2) {
  animation-delay: 0.2s;
}

.typing-indicator span:nth-child(3) {
  animation-delay: 0.4s;
}

@keyframes typing {
  0%, 60%, 100% { transform: translateY(0); }
  30% { transform: translateY(-8px); }
}
</style>

3.7 文章详情页

vue 复制代码
<!-- src/views/PostDetail.vue -->
<template>
  <div class="post-detail-container" v-if="post">
    <article class="post-content">
      <header class="post-header">
        <h1 class="post-title">{{ post.title }}</h1>
        <div class="post-meta">
          <el-avatar :size="40" :src="authorAvatar" />
          <div class="meta-info">
            <span class="author">{{ post.authorName }}</span>
            <span class="date">{{ formatDate(post.publishedAt) }}</span>
          </div>
          <div class="post-stats">
            <el-tag size="small">{{ post.viewCount }} 阅读</el-tag>
            <el-tag size="small" type="success">{{ post.likeCount }} 点赞</el-tag>
          </div>
        </div>
        <div class="post-tags" v-if="post.tags?.length">
          <el-tag 
            v-for="tag in post.tags" 
            :key="tag" 
            size="small"
            class="tag-item"
            @click="searchByTag(tag)"
          >
            {{ tag }}
          </el-tag>
        </div>
      </header>

      <div class="post-body" v-html="renderedContent" />

      <div class="post-actions">
        <el-button 
          :type="isLiked ? 'primary' : 'default'" 
          @click="handleLike"
          :icon="isLiked ? Star : StarFilled"
        >
          {{ post.likeCount }}
        </el-button>
        <el-button @click="handleShare" :icon="Share">分享</el-button>
        <el-button @click="handleCollect" :icon="Collection">收藏</el-button>
      </div>
    </article>

    <!-- 相关文章推荐 -->
    <aside class="related-posts">
      <h3>相关推荐</h3>
      <div v-for="related in relatedPosts" :key="related.id" class="related-item">
        <router-link :to="`/post/${related.id}`">
          {{ related.title }}
        </router-link>
        <span class="related-meta">{{ related.viewCount }} 阅读</span>
      </div>
    </aside>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Star, StarFilled, Share, Collection } from '@element-plus/icons-vue'
import { marked } from 'marked'
import { blogApi } from '@/api/blog'
import type { BlogPost } from '@/types'

const route = useRoute()
const router = useRouter()

const post = ref<BlogPost | null>(null)
const relatedPosts = ref<BlogPost[]>([])
const isLiked = ref(false)

const renderedContent = computed(() => {
  return post.value ? marked(post.value.content) : ''
})

const authorAvatar = computed(() => {
  return `https://api.dicebear.com/7.x/avataaars/svg?seed=${post.value?.authorName}`
})

const formatDate = (date?: string) => {
  if (!date) return ''
  return new Date(date).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

const loadPost = async () => {
  const id = Number(route.params.id)
  try {
    const { data } = await blogApi.getPost(id)
    post.value = data
    loadRelatedPosts(data.tags?.[0])
  } catch (error) {
    ElMessage.error('加载文章失败')
  }
}

const loadRelatedPosts = async (tag?: string) => {
  if (!tag) return
  try {
    const { data } = await blogApi.getPosts({ tag, size: 5 })
    relatedPosts.value = data.content.filter(p => p.id !== post.value?.id)
  } catch (error) {
    console.error('加载相关文章失败', error)
  }
}

const handleLike = async () => {
  if (!post.value) return
  isLiked.value = !isLiked.value
  post.value.likeCount += isLiked.value ? 1 : -1
  ElMessage.success(isLiked.value ? '点赞成功' : '已取消点赞')
}

const handleShare = () => {
  navigator.clipboard.writeText(window.location.href)
  ElMessage.success('链接已复制')
}

const handleCollect = () => {
  ElMessage.success('收藏成功')
}

const searchByTag = (tag: string) => {
  router.push({ path: '/search', query: { tag } })
}

onMounted(loadPost)
</script>

<style scoped>
.post-detail-container {
  max-width: 900px;
  margin: 0 auto;
  padding: 20px;
}

.post-content {
  background: #fff;
  border-radius: 8px;
  padding: 40px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.post-title {
  font-size: 32px;
  font-weight: bold;
  margin-bottom: 20px;
  line-height: 1.4;
}

.post-meta {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 20px;
  padding-bottom: 20px;
  border-bottom: 1px solid #eee;
}

.meta-info {
  display: flex;
  flex-direction: column;
}

.author {
  font-weight: bold;
}

.date {
  color: #999;
  font-size: 14px;
}

.post-stats {
  margin-left: auto;
  display: flex;
  gap: 8px;
}

.post-tags {
  margin-bottom: 20px;
}

.tag-item {
  margin-right: 8px;
  cursor: pointer;
}

.post-body {
  font-size: 16px;
  line-height: 1.8;
  color: #333;
}

.post-body :deep(h2) {
  margin-top: 32px;
  margin-bottom: 16px;
}

.post-body :deep(p) {
  margin-bottom: 16px;
}

.post-body :deep(pre) {
  background: #f5f7fa;
  padding: 16px;
  border-radius: 8px;
  overflow-x: auto;
}

.post-body :deep(code) {
  font-family: 'Monaco', 'Menlo', monospace;
}

.post-actions {
  display: flex;
  gap: 12px;
  margin-top: 40px;
  padding-top: 20px;
  border-top: 1px solid #eee;
}

.related-posts {
  margin-top: 20px;
  background: #fff;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}

.related-posts h3 {
  margin-bottom: 16px;
}

.related-item {
  display: flex;
  justify-content: space-between;
  padding: 12px 0;
  border-bottom: 1px solid #f5f5f5;
}

.related-item a {
  color: #333;
  text-decoration: none;
}

.related-item a:hover {
  color: #409eff;
}

.related-meta {
  color: #999;
  font-size: 14px;
}
</style>

四、部署与运行

4.1 Docker Compose 配置

yaml 复制代码
# docker-compose.yml
version: '3.8'

services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: ai_blog
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  milvus:
    image: milvusdb/milvus:v2.3.4
    environment:
      ETCD_USE_EMBED: "true"
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"
      - "9091:9091"
    volumes:
      - milvus_data:/var/lib/milvus

  backend:
    build: ./backend
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY}
    depends_on:
      - mysql
      - redis
      - milvus

  frontend:
    build: ./frontend
    ports:
      - "80:80"
    depends_on:
      - backend

volumes:
  mysql_data:
  redis_data:
  milvus_data:

4.2 启动命令

bash 复制代码
# 启动所有服务
docker-compose up -d

# 查看日志
docker-compose logs -f backend

# 初始化数据库
docker-compose exec backend mysql -uroot -proot ai_blog < init.sql

五、总结

本文详细介绍了如何使用 Vue3 + Spring AI 构建一个完整的 AI 智能博客平台,涵盖了:

  1. 后端架构:Spring Boot + Spring AI + Milvus 向量数据库
  2. 前端架构:Vue3 + TypeScript + Element Plus
  3. AI 功能:智能摘要、标签生成、文章润色、流式对话
  4. 语义搜索:基于 Milvus 的向量检索
  5. 部署方案:Docker Compose 一键部署

通过这个项目,你可以掌握全栈 AI 应用开发的核心技能,为构建更复杂的 AI 应用打下基础。


参考资源:

博客: https://blog.csdn.net/badao_liumang_qizhi

相关推荐
ting94520001 小时前
Monid 深度技术解析:面向 AI 智能体的统一工具路由网关实现与工程实践
人工智能·架构
合合技术团队1 小时前
合合信息亮相新加坡科技周,携手博彦科技共拓海外AI市场
大数据·人工智能·科技·合合信息·textin·多模态文本智能技术
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
哦虎!1 小时前
【JavaEE】网络原理 - TCP/IP
java
FL16238631291 小时前
塔吊下方站人检测数据集VOC+YOLO格式1049张1类别
人工智能·深度学习·yolo
AI Coder 码农王1 小时前
AgentScope Java v2 第1课,消息与事件模型
人工智能
东风微鸣1 小时前
把 Tailscale 接进 Prometheus 监控
java·数据库·prometheus
loulanyue_1 小时前
我们正从规模化时代迈向研究时代:破除预训练幻象与通用泛化困境——读Ilya Sutskever重磅专访全景解析
人工智能·深度学习·机器学习·大模型·通用人工智能