自动生成文章封面图片功能实现
最近给博客后台加了个功能:写文章时,可以根据文章标题自动生成封面图片。支持 4 种风格(渐变、科技、简约、暗黑),生成后自动上传到服务器。不用自己找图片了,AI 帮你生成。
一、功能需求
- 后台写文章时,在缩略图上传区域添加「AI 生成封面」按钮
- 支持 4 种风格选择:渐变、科技、简约、暗黑
- 根据文章标题自动绘制 800x450 的封面图
- 支持中文自动换行
- 生成后自动上传到服务器
二、技术选型
- 前端:Canvas API + Blob API(纯前端实现,不需要后端 API)
- 上传:通过现有的封面上传接口上传
三、实现思路
这个功能是纯前端实现,不需要后端 API。用 Canvas 绘制封面图,然后转为 Blob 上传。
四、前端实现
1. 页面添加按钮和风格选择
在 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="articleCover">
<div style="display: flex; flex-direction: column; gap: 8px">
<el-upload drag :show-file-list="false" :headers="authorization"
action="/api/admin/article/upload" accept="image/*"
:before-upload="beforeUpload" :on-success="handleSuccess">
<el-icon class="el-icon--upload" v-if="articleForm.articleCover === ''">
<upload-filled />
</el-icon>
<div class="el-upload__text" v-if="articleForm.articleCover === ''">
将文件拖到此处,或<em>点击上传</em>
</div>
<img v-else :src="articleForm.articleCover" width="360" />
</el-upload>
<!-- AI 生成封面 -->
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<el-button type="primary" :loading="coverLoading" :icon="MagicStick"
@click="handleGenerateCover">
{{ coverLoading ? "生成中..." : "AI 生成封面" }}
</el-button>
<el-radio-group v-model="coverStyle" size="small">
<el-radio-button label="gradient">渐变</el-radio-button>
<el-radio-button label="tech">科技</el-radio-button>
<el-radio-button label="minimal">简约</el-radio-button>
<el-radio-button label="dark">暗黑</el-radio-button>
</el-radio-group>
</div>
</div>
</el-form-item>
<!-- ... 其他表单项 ... -->
</el-form>
</el-dialog>
<!-- 隐藏的 Canvas 用于生成封面 -->
<canvas ref="coverCanvas" width="800" height="450" style="display: none"></canvas>
</div>
</template>
<script setup lang="ts">
import { uploadArticleCover } from "@/api/article";
import { MagicStick } from '@element-plus/icons-vue';
import { ElMessage } from "element-plus";
import { UploadFilled } from '@element-plus/icons-vue';
// 文章表单数据
const articleForm = ref({
articleTitle: "",
articleCover: "",
// ... 其他字段
});
// AI 生成封面相关
const coverCanvas = ref<HTMLCanvasElement>();
const coverStyle = ref("gradient");
const coverLoading = ref(false);
// 封面风格配置
const COVER_STYLES: Record<string, { bg: string[]; textColor: string; subColor: string }> = {
gradient: {
bg: ["#667eea", "#764ba2"],
textColor: "#ffffff",
subColor: "rgba(255,255,255,0.7)",
},
tech: {
bg: ["#0f2027", "#2c5364"],
textColor: "#00d2ff",
subColor: "rgba(0,210,255,0.5)",
},
minimal: {
bg: ["#ffecd2", "#fcb69f"],
textColor: "#2d3436",
subColor: "rgba(45,52,54,0.5)",
},
dark: {
bg: ["#232526", "#414345"],
textColor: "#f5f6fa",
subColor: "rgba(245,246,250,0.5)",
},
};
// 生成封面
const handleGenerateCover = async () => {
const title = articleForm.value.articleTitle;
if (!title || title.trim() === "") {
ElMessage.warning("请先输入文章标题");
return;
}
const canvas = coverCanvas.value;
if (!canvas) return;
coverLoading.value = true;
try {
const ctx = canvas.getContext("2d")!;
const style = COVER_STYLES[coverStyle.value] || COVER_STYLES.gradient;
const w = canvas.width;
const h = canvas.height;
// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, w, h);
gradient.addColorStop(0, style.bg[0]);
gradient.addColorStop(1, style.bg[1]);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
// 绘制装饰元素(半透明圆形)
ctx.globalAlpha = 0.1;
for (let i = 0; i < 5; i++) {
ctx.beginPath();
ctx.arc(
Math.random() * w,
Math.random() * h,
Math.random() * 120 + 40,
0,
Math.PI * 2
);
ctx.fillStyle = "#ffffff";
ctx.fill();
}
ctx.globalAlpha = 1;
// 绘制标题文字(自动换行)
const maxWidth = w - 100;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let fontSize = 42;
ctx.font = `bold ${fontSize}px "Microsoft YaHei", "PingFang SC", sans-serif`;
const lines = wrapText(ctx, title, maxWidth);
ctx.fillStyle = style.textColor;
const lineHeight = fontSize * 1.4;
const startY = h / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, i) => {
ctx.fillText(line, w / 2, startY + i * lineHeight);
});
// 绘制底部日期
ctx.font = `16px "Microsoft YaHei", "PingFang SC", sans-serif`;
ctx.fillStyle = style.subColor;
const date = new Date().toISOString().slice(0, 10);
ctx.fillText(date, w / 2, h - 40);
// 转为 Blob 并上传
canvas.toBlob(async (blob) => {
if (!blob) {
ElMessage.error("封面生成失败");
coverLoading.value = false;
return;
}
const file = new File([blob], `cover-${Date.now()}.png`, { type: "image/png" });
const formData = new FormData();
formData.append("file", file);
try {
const { data } = await uploadArticleCover(formData);
if (data.flag) {
articleForm.value.articleCover = data.data;
ElMessage.success("封面生成并上传成功");
} else {
ElMessage.error("封面上传失败");
}
} catch {
ElMessage.error("封面上传失败");
} finally {
coverLoading.value = false;
}
}, "image/png");
} catch {
ElMessage.error("封面生成异常");
coverLoading.value = false;
}
};
// 文字自动换行
const wrapText = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => {
const lines: string[] = [];
let currentLine = "";
for (const char of text) {
const testLine = currentLine + char;
if (ctx.measureText(testLine).width > maxWidth) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines.length > 3 ? lines.slice(0, 3) : lines; // 最多 3 行
};
// 上传成功回调
const handleSuccess = (response: any) => {
articleForm.value.articleCover = response.data;
};
</script>
2. API 函数
确保 api/article/index.ts 中有封面上传函数:
typescript
/**
* 上传文章图片
* @returns 图片链接
*/
export function uploadArticleCover(data: FormData): AxiosPromise<Result<string>> {
return request({
url: "/admin/article/upload",
headers: { "content-type": "multipart/form-data" },
method: "post",
data,
});
}
五、使用效果
- 在后台写文章页面,输入文章标题
- 选择封面风格(渐变、科技、简约、暗黑)
- 点击「AI 生成封面」按钮
- 等待几秒(按钮显示「生成中...」)
- Canvas 自动绘制 800x450 的封面图
- 生成后自动上传到服务器
- 封面 URL 自动填入表单
六、遇到的问题
1. 中文换行问题
问题 :Canvas 的 measureText 对中文字符宽度计算可能不准确,导致换行位置不对。
解决 :逐字符判断宽度,超过 maxWidth 就换行。限制最多 3 行,超出部分截断。
2. Canvas 字体问题
问题:不同系统可能没有指定的字体,导致渲染效果不一致。
解决 :使用通用字体栈:"Microsoft YaHei", "PingFang SC", sans-serif,Windows 用微软雅黑,Mac 用苹方,都没有就用系统默认无衬线字体。
3. Blob 上传问题
问题:Canvas 转为 Blob 后,需要通过 FormData 上传,但可能格式不对。
解决 :用 canvas.toBlob() 转为 Blob,再创建 File 对象,通过 FormData 上传。确保 Content-Type 是 image/png。
4. 图片质量
问题:Canvas 绘制的图片可能质量不够高。
解决:Canvas 尺寸设置为 800x450(足够清晰),绘制时使用合适的字体大小和行高,确保文字清晰可读。
七、总结
这个功能实现起来不算复杂:
- 前端:用 Canvas API 绘制封面图,支持渐变背景、文字、装饰元素
- 上传:Canvas 转为 Blob,通过现有的封面上传接口上传
关键是:
- 定义好 4 种风格的配置(背景色、文字色、辅助色)
- 实现文字自动换行(逐字符判断宽度)
- Canvas 转为 Blob 并上传
- 处理错误情况,给用户友好提示
现在写文章时,封面可以一键生成,省了不少时间。4 种风格可以满足不同需求,如果生成的不满意,也可以手动上传其他图片。
八、完整代码文件清单
前端:
views/blog/article/write.vue- 添加封面生成按钮、Canvas 绘制逻辑和上传逻辑api/article/index.ts- 确保有uploadArticleCover函数
后端:
- 不需要新增代码,使用现有的封面上传接口即可
按照上面的步骤,就可以在自己的项目中实现这个功能了。