AI智能分类标签一键搞定

后台自动选择分类和标签功能实现

最近给博客后台加了个功能:写文章时,可以让 AI 自动选择分类和标签。AI 会从已有的分类和标签中选择最合适的,如果没有合适的也可以建议新的。不用自己想了,AI 帮你搞定。

一、功能需求

  • 自动选择分类:AI 从已有分类中选择最合适的,如果没有合适的可以建议新分类
  • 自动选择标签:AI 从已有标签中选择 1-3 个,如果没有合适的可以创建新标签
  • 两个功能都在发布文章的弹窗中,点击「AI 选择」按钮触发

二、技术选型

  • 后端:Spring Boot + OkHttp + DeepSeek/硅基流动 API
  • 前端:Vue 3 + TypeScript + Axios + Element Plus

三、后端实现

1. 添加依赖

如果你的项目还没有 OkHttp 依赖,需要在 pom.xml 中添加:

xml 复制代码
<!-- OkHttp (用于 AI 请求) -->
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.12.0</version>
</dependency>

2. 配置文件

application-dev.yml 中添加 AI 配置(如果还没有):

yaml 复制代码
# AI 对话配置
ai:
  deepseek:
    api-key: ${AI_API_KEY:sk-你的API密钥}
    api-url: ${AI_API_URL:https://api.siliconflow.cn/v1/chat/completions}
    model: ${AI_MODEL:deepseek-ai/DeepSeek-V3-0324}
    system-prompt: ${AI_SYSTEM_PROMPT:你是一个博客智能助手,帮助用户解答技术问题。请用简洁、专业的中文回答,支持 Markdown 格式。}

3. AI 服务类

AiService.java 中添加两个方法:

java 复制代码
package com.ican.service;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ican.config.properties.DeepSeekProperties;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class AiService {

    @Autowired
    private DeepSeekProperties deepSeekProperties;

    private final OkHttpClient httpClient = new OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(120, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .build();

    /**
     * 自动选择分类
     *
     * @param content 文章内容
     * @param categories 已有分类列表(逗号分隔)
     * @return 分类名称
     */
    public String autoCategory(String content, String categories) {
        // 截取前 2000 字,足够判断分类
        String truncated = content.length() > 2000 ? content.substring(0, 2000) : content;

        JSONArray messagesArray = new JSONArray();

        // 系统提示词
        JSONObject systemMsg = new JSONObject();
        systemMsg.put("role", "system");
        systemMsg.put("content", "你是一个文章分类助手。根据文章内容,从给定的分类列表中选择最合适的一个分类。\n" +
                "如果没有合适的分类,可以建议一个新的分类名称(不超过10个字)。\n" +
                "要求:只输出分类名称,不要加任何解释。");
        messagesArray.add(systemMsg);

        // 用户消息
        JSONObject userMsg = new JSONObject();
        userMsg.put("role", "user");
        userMsg.put("content", "已有分类列表:" + (categories != null ? categories : "无") +
                "\n\n文章内容:\n" + truncated);
        messagesArray.add(userMsg);

        JSONObject requestBody = new JSONObject();
        requestBody.put("model", deepSeekProperties.getModel());
        requestBody.put("messages", messagesArray);
        requestBody.put("stream", false); // 非流式,同步返回
        requestBody.put("temperature", 0.3); // 降低随机性,分类选择更稳定
        requestBody.put("max_tokens", 30); // 限制输出长度,分类名称不会太长

        Request request = new Request.Builder()
                .url(deepSeekProperties.getApiUrl())
                .addHeader("Authorization", "Bearer " + deepSeekProperties.getApiKey())
                .addHeader("Content-Type", "application/json")
                .post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                String body = response.body() != null ? response.body().string() : "";
                log.error("AI 分类选择失败: {} {}", response.code(), body);
                throw new RuntimeException("AI 分类选择失败: " + response.code());
            }
            String responseBody = response.body().string();
            JSONObject jsonResponse = JSON.parseObject(responseBody);
            JSONArray choices = jsonResponse.getJSONArray("choices");
            if (choices != null && !choices.isEmpty()) {
                JSONObject message = choices.getJSONObject(0).getJSONObject("message");
                if (message != null) {
                    String result = message.getString("content");
                    return result != null ? result.trim() : "";
                }
            }
            return "";
        } catch (IOException e) {
            log.error("AI 分类选择异常", e);
            throw new RuntimeException("AI 分类选择异常: " + e.getMessage());
        }
    }

    /**
     * 自动选择标签
     *
     * @param content 文章内容
     * @param tags 已有标签列表(逗号分隔)
     * @return 标签名列表(最多3个)
     */
    public List<String> autoTags(String content, String tags) {
        // 截取前 2000 字,足够判断标签
        String truncated = content.length() > 2000 ? content.substring(0, 2000) : content;

        JSONArray messagesArray = new JSONArray();

        // 系统提示词
        JSONObject systemMsg = new JSONObject();
        systemMsg.put("role", "system");
        systemMsg.put("content", "你是一个文章标签生成助手。根据文章内容,选择或生成1-3个合适的标签。\n" +
                "优先从已有标签列表中选择,如果没有合适的可以创建新标签。\n" +
                "要求:只输出标签名,用逗号分隔,不要加解释。例如:Java,Spring Boot,数据库");
        messagesArray.add(systemMsg);

        // 用户消息
        JSONObject userMsg = new JSONObject();
        userMsg.put("role", "user");
        userMsg.put("content", "已有标签列表:" + (tags != null ? tags : "无") +
                "\n\n文章内容:\n" + truncated);
        messagesArray.add(userMsg);

        JSONObject requestBody = new JSONObject();
        requestBody.put("model", deepSeekProperties.getModel());
        requestBody.put("messages", messagesArray);
        requestBody.put("stream", false); // 非流式,同步返回
        requestBody.put("temperature", 0.3); // 降低随机性,标签选择更稳定
        requestBody.put("max_tokens", 60); // 限制输出长度,约 1-3 个标签

        Request request = new Request.Builder()
                .url(deepSeekProperties.getApiUrl())
                .addHeader("Authorization", "Bearer " + deepSeekProperties.getApiKey())
                .addHeader("Content-Type", "application/json")
                .post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                String body = response.body() != null ? response.body().string() : "";
                log.error("AI 标签选择失败: {} {}", response.code(), body);
                throw new RuntimeException("AI 标签选择失败: " + response.code());
            }
            String responseBody = response.body().string();
            JSONObject jsonResponse = JSON.parseObject(responseBody);
            JSONArray choices = jsonResponse.getJSONArray("choices");
            if (choices != null && !choices.isEmpty()) {
                JSONObject message = choices.getJSONObject(0).getJSONObject("message");
                if (message != null) {
                    String result = message.getString("content");
                    if (result == null || result.trim().isEmpty()) {
                        return new ArrayList<>();
                    }
                    // 按逗号或中文逗号分割
                    String[] tagArray = result.trim().split("[,,、]");
                    List<String> tagList = new ArrayList<>();
                    for (String t : tagArray) {
                        String trimmed = t.trim();
                        if (!trimmed.isEmpty()) {
                            tagList.add(trimmed);
                        }
                    }
                    // 限制最多 3 个标签
                    return tagList.size() > 3 ? tagList.subList(0, 3) : tagList;
                }
            }
            return new ArrayList<>();
        } catch (IOException e) {
            log.error("AI 标签选择异常", e);
            throw new RuntimeException("AI 标签选择异常: " + e.getMessage());
        }
    }
}

关键点说明

  • temperature: 0.3:降低随机性,选择更稳定
  • max_tokens: 30/60:限制输出长度
  • 截取前 2000 字:足够判断分类/标签
  • 系统提示词明确要求:只输出名称,不要解释
  • 标签分割:支持英文逗号、中文逗号、顿号
  • 标签数量限制:最多 3 个

4. 控制器

AiController.java 中添加两个接口:

java 复制代码
package com.ican.controller;

import com.ican.model.vo.Result;
import com.ican.service.AiService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

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

/**
 * AI 对话控制器
 *
 * @author ican
 */
@Api(tags = "AI 模块")
@RestController
public class AiController {

    @Autowired
    private AiService aiService;

    /**
     * AI 自动选择分类
     *
     * @param body 请求体,包含 content(文章内容)和 categories(已有分类,逗号分隔)
     * @return 分类名称
     */
    @ApiOperation(value = "AI 自动选择分类")
    @PostMapping("/admin/ai/category")
    public Result<String> autoCategory(@RequestBody Map<String, String> body) {
        String content = body.get("content");
        String categories = body.get("categories");
        if (content == null || content.trim().isEmpty()) {
            return Result.fail("文章内容不能为空");
        }
        return Result.success(aiService.autoCategory(content, categories));
    }

    /**
     * AI 自动选择标签
     *
     * @param body 请求体,包含 content(文章内容)和 tags(已有标签,逗号分隔)
     * @return 标签名列表(最多3个)
     */
    @ApiOperation(value = "AI 自动选择标签")
    @PostMapping("/admin/ai/tags")
    public Result<List<String>> autoTags(@RequestBody Map<String, String> body) {
        String content = body.get("content");
        String tags = body.get("tags");
        if (content == null || content.trim().isEmpty()) {
            return Result.fail("文章内容不能为空");
        }
        return Result.success(aiService.autoTags(content, tags));
    }
}

接口放在 /admin/ 路径下,需要登录才能访问(后台管理功能)。

四、前端实现

1. API 函数

api/article/index.ts 中添加:

typescript 复制代码
import { Result } from "@/model";
import request from "@/utils/request";
import { AxiosPromise } from "axios";

/**
 * AI 自动选择分类
 * @param content 文章内容
 * @param categories 已有分类(逗号分隔)
 * @returns 分类名称
 */
export function generateAiCategory(content: string, categories: string): AxiosPromise<Result<string>> {
  return request({
    url: "/admin/ai/category",
    method: "post",
    timeout: 60000,
    data: { content, categories },
  });
}

/**
 * AI 自动选择标签
 * @param content 文章内容
 * @param tags 已有标签(逗号分隔)
 * @returns 标签名列表
 */
export function generateAiTags(content: string, tags: string): AxiosPromise<Result<string[]>> {
  return request({
    url: "/admin/ai/tags",
    method: "post",
    timeout: 60000,
    data: { content, tags },
  });
}

/**
 * 获取分类选项
 * @returns 分类选项
 */
export function getCategoryOption(): AxiosPromise<Result<CategoryVO[]>> {
  return request({
    url: "/admin/category/option",
    method: "get",
  });
}

/**
 * 获取标签选项
 * @returns 标签选项
 */
export function getTagOption(): AxiosPromise<Result<TagVO[]>> {
  return request({
    url: "/admin/tag/option",
    method: "get",
  });
}

2. 页面添加按钮和逻辑

views/blog/article/write.vue 中:

vue 复制代码
<template>
    <div class="app-container">
        <!-- ... 文章编辑区域 ... -->

        <!-- 发布或修改对话框 -->
        <el-dialog title="发布文章" v-model="addOrUpdate" width="600px" top="0.5vh" append-to-body>
            <el-form ref="articleFormRef" label-width="80px" :model="articleForm" :rules="rules">
                <!-- 文章分类 -->
                <el-form-item label="文章分类" prop="categoryName">
                    <el-tag type="success" v-show="articleForm.categoryName" 
                            :disable-transitions="true" :closable="true" @close="removeCategory">
                        {{ articleForm.categoryName }}
                    </el-tag>
                    <!-- 分类选项 -->
                    <el-popover v-if="!articleForm.categoryName" placement="bottom-start" width="460" trigger="click">
                        <template #reference>
                            <el-button type="success" plain>添加分类</el-button>
                        </template>
                        <!-- ... 分类选择组件 ... -->
                    </el-popover>
                    <el-button v-if="!articleForm.categoryName" type="primary" 
                               :loading="aiCategoryLoading" :icon="MagicStick" 
                               style="margin-left: 8px" @click="handleAiCategory">
                        AI 选择
                    </el-button>
                </el-form-item>

                <!-- 文章标签 -->
                <el-form-item label="文章标签" prop="tagNameList">
                    <el-tag v-for="(item, index) of articleForm.tagNameList" :key="index"
                            :disable-transitions="true" :closable="true" @close="removeTag(item)"
                            style="margin-right: 1rem">
                        {{ item }}
                    </el-tag>
                    <!-- 标签选项 -->
                    <el-popover placement="bottom-start" width="460" trigger="click" 
                                v-if="articleForm.tagNameList.length < 3">
                        <template #reference>
                            <el-button type="success" plain>添加标签</el-button>
                        </template>
                        <!-- ... 标签选择组件 ... -->
                    </el-popover>
                    <el-button v-if="articleForm.tagNameList.length === 0" type="primary" 
                               :loading="aiTagLoading" :icon="MagicStick" 
                               style="margin-left: 8px" @click="handleAiTags">
                        AI 选择
                    </el-button>
                </el-form-item>

                <!-- ... 其他表单项 ... -->
            </el-form>
            <template #footer>
                <div class="dialog-footer">
                    <el-button v-if="articleForm.status != 3" type="danger" @click="submitForm">发布文章</el-button>
                    <el-button v-else type="danger" @click="submitForm">保存草稿</el-button>
                    <el-button @click="addOrUpdate = false">取 消</el-button>
                </div>
            </template>
        </el-dialog>
    </div>
</template>

<script setup lang="ts">
import { generateAiCategory, generateAiTags, getCategoryOption, getTagOption } from "@/api/article";
import { CategoryVO, TagVO } from "@/api/article/types";
import { MagicStick } from '@element-plus/icons-vue';
import { ElMessage } from "element-plus";

// 文章表单数据
const articleForm = ref({
    articleTitle: "",
    articleContent: "",
    categoryName: "",
    tagNameList: [] as string[],
    // ... 其他字段
});

// 分类和标签列表
const categoryList = ref<CategoryVO[]>([]);
const tagList = ref<TagVO[]>([]);

// AI 自动选择分类
const aiCategoryLoading = ref(false);
const handleAiCategory = async () => {
    const content = articleForm.value.articleContent;
    if (!content || content.trim() === "") {
        ElMessage.warning("请先编写文章内容");
        return;
    }
    aiCategoryLoading.value = true;
    try {
        // 确保分类列表已加载
        if (categoryList.value.length === 0) {
            const res = await getCategoryOption();
            categoryList.value = res.data.data;
        }
        const categoriesStr = categoryList.value.map((c) => c.categoryName).join(",");
        const { data } = await generateAiCategory(content, categoriesStr);
        if (data.flag && data.data) {
            articleForm.value.categoryName = data.data.trim();
            ElMessage.success(`AI 已选择分类: ${articleForm.value.categoryName}`);
        } else {
            ElMessage.error(data.msg || "AI 分类选择失败");
        }
    } catch {
        ElMessage.error("AI 分类选择失败,请稍后重试");
    } finally {
        aiCategoryLoading.value = false;
    }
};

// AI 自动选择标签
const aiTagLoading = ref(false);
const handleAiTags = async () => {
    const content = articleForm.value.articleContent;
    if (!content || content.trim() === "") {
        ElMessage.warning("请先编写文章内容");
        return;
    }
    aiTagLoading.value = true;
    try {
        // 确保标签列表已加载
        if (tagList.value.length === 0) {
            const res = await getTagOption();
            tagList.value = res.data.data;
        }
        const tagsStr = tagList.value.map((t) => t.tagName).join(",");
        const { data } = await generateAiTags(content, tagsStr);
        if (data.flag && data.data) {
            const newTags = data.data;
            newTags.forEach((t: string) => {
                if (articleForm.value.tagNameList.indexOf(t) === -1 && 
                    articleForm.value.tagNameList.length < 3) {
                    articleForm.value.tagNameList.push(t);
                }
            });
            ElMessage.success(`AI 已选择标签: ${newTags.join(", ")}`);
        } else {
            ElMessage.error(data.msg || "AI 标签选择失败");
        }
    } catch {
        ElMessage.error("AI 标签选择失败,请稍后重试");
    } finally {
        aiTagLoading.value = false;
    }
};

// 打开发布对话框时加载分类和标签列表
const openModel = () => {
    // ... 其他逻辑 ...
    getCategoryOption().then(({ data }) => {
        categoryList.value = data.data;
    });
    getTagOption().then(({ data }) => {
        tagList.value = data.data;
    });
    addOrUpdate.value = true;
};

// 移除分类
const removeCategory = () => {
    articleForm.value.categoryName = "";
};

// 移除标签
const removeTag = (item: string) => {
    const index = articleForm.value.tagNameList.indexOf(item);
    articleForm.value.tagNameList.splice(index, 1);
};
</script>

五、使用效果

  1. 在后台写文章页面,写好文章内容
  2. 点击「发布文章」打开弹窗
  3. 在「文章分类」下方点击「AI 选择」按钮
  4. 等待几秒(按钮显示 loading)
  5. AI 自动选择分类并填入(如果没有合适的分类,会建议新分类)
  6. 在「文章标签」下方点击「AI 选择」按钮
  7. AI 自动选择 1-3 个标签并填入(如果没有合适的标签,会创建新标签)

六、遇到的问题

1. AI 返回格式问题

问题:AI 可能返回带解释的文本(如「根据文章内容,我建议选择"后端开发"这个分类」)。

解决:在系统提示词中明确要求「只输出分类名称,不要加任何解释」。后端再做一些清理。

2. 标签数量控制

问题:AI 可能返回超过 3 个标签。

解决 :在系统提示词中明确要求「1-3个标签」。后端限制最多 3 个:tagList.size() > 3 ? tagList.subList(0, 3) : tagList

3. 分类/标签列表未加载

问题:用户点击「AI 选择」时,分类/标签列表可能还没加载。

解决 :前端检查列表是否为空,为空时先调用 getCategoryOption()getTagOption() 加载列表。

4. 标签分割问题

问题:AI 可能用中文逗号或顿号分隔标签。

解决 :后端用正则表达式分割:split("[,,、]"),支持英文逗号、中文逗号、顿号。

七、总结

这两个功能实现起来不算复杂:

  1. 后端:新增两个同步调用的 AI 接口,分别用于选择分类和标签
  2. 前端:加个按钮,调用 API,把结果填入表单

关键是:

  • 系统提示词要明确(只输出名称,不要解释)
  • 设置合适的 temperature(降低随机性)和 max_tokens(限制长度)
  • 前端设置足够的超时时间
  • 处理错误情况,给用户友好提示
  • 后端处理标签分割和数量限制

现在写文章时,分类和标签可以一键选择,省了不少时间。如果选择的不满意,也可以手动修改或删除。

八、完整代码文件清单

后端

  • pom.xml - 添加 OkHttp 依赖
  • application-dev.yml - 添加 AI 配置
  • AiService.java - 添加 autoCategoryautoTags 方法
  • AiController.java - 添加 /admin/ai/category/admin/ai/tags 接口

前端

  • api/article/index.ts - 添加 generateAiCategorygenerateAiTags 函数
  • views/blog/article/write.vue - 添加分类和标签的 AI 选择按钮和逻辑

按照上面的步骤,就可以在自己的项目中实现这个功能了。

相关推荐
安科士andxe4 小时前
深入解析|安科士1.25G CWDM SFP光模块核心技术,破解中长距离传输痛点
服务器·网络·5g
九.九6 小时前
ops-transformer:AI 处理器上的高性能 Transformer 算子库
人工智能·深度学习·transformer
春日见6 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
恋猫de小郭6 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
YJlio6 小时前
1.7 通过 Sysinternals Live 在线运行工具:不下载也能用的“云端工具箱”
c语言·网络·python·数码相机·ios·django·iphone
deephub6 小时前
Agent Lightning:微软开源的框架无关 Agent 训练方案,LangChain/AutoGen 都能用
人工智能·microsoft·langchain·大语言模型·agent·强化学习
CTRA王大大7 小时前
【网络】FRP实战之frpc全套配置 - fnos飞牛os内网穿透(全网最通俗易懂)
网络
大模型RAG和Agent技术实践7 小时前
从零构建本地AI合同审查系统:架构设计与流式交互实战(完整源代码)
人工智能·交互·智能合同审核
老邋遢7 小时前
第三章-AI知识扫盲看这一篇就够了
人工智能
互联网江湖7 小时前
Seedance2.0炸场:长短视频们“修坝”十年,不如AI放水一天?
人工智能