通过故事生成插画
前端
<template>
<div class="cartoon-theme" style="padding: 20px">
<div class="hero-banner">
<img src="@/image/illstation.png" class="hero-image" alt="插画测试" />
</div>
<div class="split-layout">
<div class="left-pane">
<el-card class="fixed-panel">
<template #header>
<div class="card-header">
<div class="card-title"><span class="card-icon">📚</span><span>故事列表与操作</span></div>
</div>
</template>
<div class="actions-row" style="margin-bottom: 12px">
<el-input v-model="searchForm.keywords" placeholder="关键词" style="width: 260px" />
<el-button type="primary" @click="searchStories">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-input-number v-model="maxImages" :min="1" :max="8" label="张数" />
</div>
<div v-if="illProgressVisible" style="margin: 8px 0">
<el-progress :percentage="illProgress" :text-inside="true" :stroke-width="16" />
</div>
<el-table :data="storyList" v-loading="loading" element-loading-text="加载中..." style="width: 100%">
<el-table-column type="index" :index="indexMethod" label="序号" width="80" />
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="keywords" label="关键词" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag v-if="scope.row.status === 1" type="success">正常</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column label="插画" width="200">
<template #default="scope">
<div class="row-actions">
<el-button size="small" type="primary" @click="generateFor(scope.row)">生成插画</el-button>
<el-button size="small" @click="previewIll(scope.row)">预览</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 12px; display:flex; justify-content:flex-end">
<el-pagination
v-model:current-page="pageNum"
v-model:page-size="pageSize"
:page-sizes="[10,20,50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@current-change="changePage"
@size-change="changeSize"
/>
</div>
</el-card>
</div>
<div class="right-pane">
<el-card class="fixed-panel">
<template #header>
<div class="card-header">
<div class="card-title"><span class="card-icon">🖼️</span><span>图片预览</span></div>
</div>
</template>
<div v-if="selectedStory">
<div style="margin-bottom:8px; font-weight:600">{{ selectedStory.title }}</div>
<el-alert v-if="imageLoading" title="加载中..." type="info" show-icon />
<div v-else>
<div v-if="imageUrls.length">
<div class="carousel-controls">
<el-switch v-model="autoPlay" active-text="自动轮播" />
<el-input-number v-model="carouselInterval" :min="1000" :max="10000" :step="500" />
</div>
<el-carousel
ref="illCarousel"
:interval="carouselInterval"
:autoplay="autoPlay"
arrow="always"
:pause-on-hover="false"
height="320px"
type="card"
@change="onCarouselChange"
>
<el-carousel-item v-for="(u,i) in imageUrls" :key="u + i" :name="'img-' + i">
<div class="carousel-item">
<el-image :src="u" fit="contain" :preview-src-list="imageUrls" style="width:100%;height:100%" />
</div>
</el-carousel-item>
</el-carousel>
<div class="thumb-strip">
<div class="thumb" v-for="(u,i) in imageUrls" :key="u + 't' + i" :class="{ active: i === currentIndex }" @click="jumpTo(i)">
<el-image :src="u" fit="cover" style="width:100%;height:100%" />
</div>
</div>
</div>
<div v-else class="placeholder">暂无插画</div>
<el-divider>故事内容</el-divider>
<div class="story-detail-body" v-html="formatStoryBody(selectedStory.body)"></div>
</div>
</div>
<div v-else class="placeholder">在左侧选择故事并点击预览</div>
</el-card>
</div>
</div>
<el-dialog v-model="pathsDialog" title="生成结果" width="780px">
<div v-if="generatedPaths.length">
<el-alert title="图片已保存到服务端资源目录" type="info" show-icon />
<el-divider />
<div v-if="generatedUrls.length" class="img-grid">
<el-image
v-for="(u,i) in generatedUrls"
:key="u + i"
:src="u"
fit="cover"
:preview-src-list="generatedUrls"
style="width: 220px; height: 160px"
/>
</div>
<div v-else>
<el-scrollbar height="320px">
<ul class="path-list">
<li class="path-item" v-for="p in generatedPaths" :key="p">{{ p }}</li>
</ul>
</el-scrollbar>
</div>
</div>
<div v-else>暂无数据</div>
<template #footer>
<el-button @click="pathsDialog=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'IllustrationTest',
data() {
return {
searchForm: { keywords: '' },
pageNum: 1,
pageSize: 10,
total: 0,
storyList: [],
loading: false,
maxImages: 4,
pathsDialog: false,
generatedPaths: [],
generatedUrls: [],
selectedStory: null,
imageList: [],
imageUrls: [],
imageLoading: false
,autoPlay: true
,carouselInterval: 4000
,currentIndex: 0
,illProgress: 0
,illProgressVisible: false
,illProgressTimer: null
}
},
mounted() {
this.loadStories()
},
methods: {
indexMethod(i) {
return (this.pageNum - 1) * this.pageSize + i + 1
},
formatStoryBody(body) {
if (!body) return ''
return body.replace(/\n/g, '<br>').replace(/\n\n/g, '<br><br>')
},
loadStories() {
this.loading = true
const params = {
pageNum: this.pageNum,
pageSize: this.pageSize,
param: { keywords: this.searchForm.keywords }
}
this.$axios.post('/story/list', params).then(res => {
this.loading = false
if (res.data.code === 200) {
this.storyList = res.data.data || []
this.total = res.data.total || 0
} else {
this.storyList = []
this.total = 0
this.$message.error(res.data.msg || '获取故事失败')
}
}).catch(() => {
this.loading = false
this.$message.error('获取故事失败')
})
},
searchStories() {
this.pageNum = 1
this.loadStories()
},
resetSearch() {
this.searchForm.keywords = ''
this.pageNum = 1
this.loadStories()
},
changePage(p) {
this.pageNum = p
this.loadStories()
},
changeSize(s) {
this.pageSize = s
this.loadStories()
},
generateFor(row) {
if (!row?.id) return
const body = { storyId: row.id, maxImages: this.maxImages }
console.log(`[${this.ts()}] 开始生成: storyId=${row.id}, maxImages=${this.maxImages}`)
console.log(`[${this.ts()}] 请求: POST /illustration/generate body=` + JSON.stringify(body))
this.startIllProgress()
this.loading = true
this.$axios.post('/illustration/generate', body, { timeout: 180000 }).then(res => {
this.loading = false
this.stopIllProgress(true)
console.log(`[${this.ts()}] 响应: http=${res.status}, appCode=${res.data?.code}`)
if (res.data.code === 200) {
this.generatedPaths = res.data.data || []
const base = this.$axios?.defaults?.baseURL || ''
this.generatedUrls = (this.generatedPaths || []).map(p => {
const parts = String(p).split(/[\\\/]/)
const name = parts[parts.length - 1]
return `${base}/illustration/file/by-name/${encodeURIComponent(name)}`
})
this.pathsDialog = true
console.log(`[${this.ts()}] 生成成功, 路径数量=${this.generatedPaths.length}`)
this.$message.success('生成成功')
} else {
console.log(`[${this.ts()}] 生成失败: ${res.data.msg || '未知错误'}`)
this.$message.error(res.data.msg || '生成失败')
}
}).catch(err => {
this.loading = false
this.stopIllProgress(false)
const isTimeout = err?.code === 'ECONNABORTED' || /timeout/i.test(err?.message || '')
const msg = isTimeout ? '请求超时,请稍后重试或增大超时' : (err?.response?.data?.msg || '生成失败')
const http = err?.response?.status
const payload = err?.response?.data ? JSON.stringify(err.response.data) : ''
console.log(`[${this.ts()}] 请求异常: http=${http} msg=${msg}`)
if (payload) console.log(`[${this.ts()}] 错误载荷: ${payload}`)
this.$message.error(msg)
})
},
startIllProgress() {
this.illProgressVisible = true
this.illProgress = 0
if (this.illProgressTimer) { clearInterval(this.illProgressTimer); this.illProgressTimer = null }
this.illProgressTimer = setInterval(() => {
const inc = Math.floor(Math.random() * 5) + 1
if (this.illProgress < 95) this.illProgress = Math.min(95, this.illProgress + inc)
}, 200)
},
stopIllProgress(success) {
if (this.illProgressTimer) { clearInterval(this.illProgressTimer); this.illProgressTimer = null }
this.illProgress = success ? 100 : this.illProgress
setTimeout(() => { this.illProgressVisible = false; this.illProgress = 0 }, 500)
},
previewIll(row) {
if (!row?.id) return
this.selectedStory = row
this.imageLoading = true
this.$axios.get(`/illustration/by-story/${row.id}`).then(res => {
this.imageLoading = false
if (res.data.code === 200) {
this.imageList = res.data.data || []
const base = this.$axios?.defaults?.baseURL || ''
this.imageUrls = (this.imageList || []).map(x => `${base}/illustration/file/${x.id}`)
this.currentIndex = 0
this.$nextTick(() => { const c = this.$refs.illCarousel; if (c) c.setActiveItem('img-0') })
} else {
this.$message.error(res.data.msg || '查询失败')
this.imageList = []
this.imageUrls = []
}
}).catch(() => {
this.imageLoading = false
this.$message.error('查询失败')
this.imageList = []
this.imageUrls = []
})
},
onCarouselChange(i) {
this.currentIndex = i
},
jumpTo(i) {
const c = this.$refs.illCarousel
if (c) c.setActiveItem('img-' + i)
this.currentIndex = i
},
ts() {
const d = new Date()
const p = n => (n<10?('0'+n):n)
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
}
}
</script>
<style scoped>
.page-hero { background: linear-gradient(90deg,#f3e5f5,#e1bee7); border: 1px solid #e1bee7; border-radius: 16px; padding: 18px; box-shadow: 0 6px 16px rgba(156,39,176,0.12); margin-bottom: 16px; }
.hero-ill { }
.hero-title { font-size: 22px; font-weight: 700; color: #3e2723; }
.hero-sub { color: #6d4c41; margin-top: 6px; }
.hero-banner { margin-bottom: 16px; height: 180px; border-radius: 16px; overflow: hidden; box-shadow: 0 6px 16px rgba(156,39,176,0.12); border: 1px solid #e1bee7; }
.hero-image { width: 100%; height: 100%; display: block; object-fit: cover; }
.path-list {
list-style: none;
padding: 0;
margin: 0;
}
.path-item {
padding: 6px 8px;
border-bottom: 1px solid #eee;
font-family: Consolas, Monaco, monospace;
word-break: break-all;
}
.split-layout { display: flex; gap: 16px; align-items: flex-start; }
.left-pane { flex: 1; min-width: 420px; }
.right-pane { width: 48%; }
.actions-row { display: flex; gap: 8px; align-items: center; flex-wrap: nowrap; }
.row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: nowrap; }
.fixed-panel { height: 900px; display: flex; flex-direction: column; }
.fixed-panel .el-card__body { overflow: auto; }
.placeholder { color: #909399; padding: 20px; text-align: center; }
.img-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.carousel-controls { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
.carousel-item { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
.thumb-strip { display: flex; gap: 8px; margin-top: 10px; overflow-x: auto; padding-bottom: 6px; }
.thumb { width: 80px; height: 60px; border: 2px solid transparent; border-radius: 8px; flex: 0 0 auto; }
.thumb.active { border-color: #ff9a9e; }
.el-carousel--card .el-carousel__item .carousel-item { transform: scale(0.80); transition: transform .2s ease, opacity .2s ease; opacity: .9; }
.el-carousel--card .el-carousel__item.is-active .carousel-item { transform: scale(1); opacity: 1; }
.el-carousel--card .el-carousel__item:not(.is-active) .carousel-item { transform: scale(0.60); opacity: .45; }
.story-detail-body { font-size: 14px; line-height: 1.8; color: #606266; white-space: normal; }
.cartoon-theme .el-card { border-radius: 16px; border: 1px solid rgba(0,0,0,0.06); box-shadow: 0 10px 20px rgba(0,0,0,0.08); }
.cartoon-theme .el-dialog { border-radius: 16px; overflow: hidden; }
.cartoon-theme .el-dialog__header { background: linear-gradient(90deg,#f3e5f5,#e1bee7); color: #3e2723; }
.cartoon-theme .el-button { border-radius: 18px; font-weight: 600; }
.cartoon-theme .el-button--primary { background: linear-gradient(90deg,#ff9a9e,#fecfef); border-color: #ff9a9e; }
.cartoon-theme { background: url('@/image/storyBG.png') center/cover no-repeat; }
.card-header { display: flex; align-items: center; }
.card-title { display: flex; align-items: center; gap: 10px; font-weight: 700; color: #303133; font-size: 18px; }
.card-icon { font-size: 22px; line-height: 1; padding: 4px 8px; border-radius: 999px; background: rgba(255,255,255,0.92); box-shadow: 0 2px 6px rgba(0,0,0,0.15); }
</style>
后端使用豆包的api
package com.example.demo.service;
import com.example.demo.entity.Illustration;
import com.example.demo.entity.Story;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.*;
@Service
public class IllustrationApiService {
@Autowired
private RestTemplate restTemplate;
@Autowired
private StoryService storyService;
@Autowired
private IllustrationService illustrationService;
private static final String API_URL = "https://ark.cn-beijing.volces.com/api/v3/images/generations";
private static final String API_KEY = "";
private static final String MODEL = "doubao-seedream-4-0-250828";
public List<String> generateAndSave(Long storyId, Integer maxImages) {
Story story = storyService.getById(storyId);
if (story == null || StringUtils.isBlank(story.getBody())) {
throw new RuntimeException("故事不存在或正文为空");
}
int count = maxImages == null || maxImages <= 0 ? 4 : Math.min(maxImages, 8);
List<byte[]> b64imgs = fetchImages(count, story.getBody());
Path saveDir = Paths.get("d:\\Project\\2025aunt\\KIDSTORY2.0\\Spring\\demo\\src\\main\\resources\\image");
try {
Files.createDirectories(saveDir);
} catch (Exception e) {
throw new RuntimeException("创建保存目录失败: " + saveDir.toString());
}
List<String> saved = new ArrayList<>();
long ts = Instant.now().toEpochMilli();
int i = 0;
if (b64imgs != null && !b64imgs.isEmpty()) {
if (b64imgs.size() < count) {
int attempts = 0;
while (b64imgs.size() < count && attempts < 3) {
attempts++;
List<byte[]> more = fetchImages(count - b64imgs.size(), story.getBody());
if (more != null && !more.isEmpty()) b64imgs.addAll(more);
else break;
}
}
for (byte[] bytes : b64imgs) {
if (i >= count) break;
i++;
String fname = "story_" + storyId + "_" + ts + "_" + i + ".png";
Path fpath = saveDir.resolve(fname);
try (FileOutputStream fos = new FileOutputStream(fpath.toFile())) {
fos.write(bytes);
} catch (Exception e) {
throw new RuntimeException("保存图片失败: " + fpath.toString());
}
Illustration ill = new Illustration();
ill.setStoryId(storyId);
ill.setFilePath(fpath.toString());
illustrationService.save(ill);
saved.add(fpath.toString());
}
} else {
Map<String, Object> debugOnce = new HashMap<>();
debugOnce.put("model", MODEL);
debugOnce.put("prompt", story.getBody());
debugOnce.put("sequential_image_generation", "auto");
Map<String, Object> opt2 = new HashMap<>();
opt2.put("max_images", count);
debugOnce.put("sequential_image_generation_options", opt2);
debugOnce.put("response_format", "url");
debugOnce.put("size", "2K");
debugOnce.put("stream", false);
debugOnce.put("watermark", true);
HttpHeaders headers2 = new HttpHeaders();
headers2.setContentType(MediaType.APPLICATION_JSON);
headers2.set("Authorization", "Bearer " + API_KEY);
HttpEntity<Map<String, Object>> request2 = new HttpEntity<>(debugOnce, headers2);
ResponseEntity<String> resp2 = restTemplate.postForEntity(API_URL, request2, String.class);
if (!resp2.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("插画生成失败: " + resp2.getStatusCodeValue());
}
List<String> urls = extractImageUrls(resp2.getBody());
if (urls.isEmpty()) {
String err = parseErrorMessage(resp2.getBody());
if (err == null || err.isEmpty()) err = "未获取到插画数据";
throw new RuntimeException(err);
}
for (String url : urls) {
if (i >= count) break;
i++;
byte[] bytes = download(url);
String ext = guessExt(url);
String fname = "story_" + storyId + "_" + ts + "_" + i + ext;
Path fpath = saveDir.resolve(fname);
try (FileOutputStream fos = new FileOutputStream(fpath.toFile())) {
fos.write(bytes);
} catch (Exception e) {
throw new RuntimeException("保存图片失败: " + fpath.toString());
}
Illustration ill = new Illustration();
ill.setStoryId(storyId);
ill.setFilePath(fpath.toString());
illustrationService.save(ill);
saved.add(fpath.toString());
}
}
return saved;
}
private List<byte[]> fetchImages(int wantCount, String prompt) {
Map<String, Object> body = new HashMap<>();
body.put("model", MODEL);
body.put("prompt", prompt);
body.put("sequential_image_generation", "auto");
Map<String, Object> opt = new HashMap<>();
opt.put("max_images", wantCount);
body.put("sequential_image_generation_options", opt);
body.put("response_format", "b64_json");
body.put("size", "2K");
body.put("stream", false);
body.put("watermark", true);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + API_KEY);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> resp = restTemplate.postForEntity(API_URL, request, String.class);
if (!resp.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("插画生成失败: " + resp.getStatusCodeValue());
}
List<byte[]> imgs = extractBase64Images(resp.getBody());
if (imgs == null || imgs.isEmpty()) {
String err = parseErrorMessage(resp.getBody());
if (err != null && !err.isEmpty()) throw new RuntimeException(err);
}
return imgs;
}
private byte[] download(String url) {
ResponseEntity<byte[]> img = restTemplate.getForEntity(url, byte[].class);
if (!img.getStatusCode().is2xxSuccessful() || img.getBody() == null) {
throw new RuntimeException("下载图片失败");
}
return img.getBody();
}
private String guessExt(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.contains(".png")) return ".png";
if (lower.contains(".jpg") || lower.contains(".jpeg")) return ".jpg";
return ".png";
}
private List<String> extractImageUrls(String json) {
List<String> res = new ArrayList<>();
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(json);
if (root.has("data") && root.get("data").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode n : root.get("data")) {
if (n.has("url")) {
String u = n.get("url").asText("");
if (!u.isEmpty()) res.add(u);
}
}
}
if (res.isEmpty()) {
if (root.has("images") && root.get("images").has("data") && root.get("images").get("data").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode n : root.get("images").get("data")) {
if (n.has("url")) {
String u = n.get("url").asText("");
if (!u.isEmpty()) res.add(u);
}
}
}
}
} catch (Exception e) {
return Collections.emptyList();
}
return res;
}
private List<byte[]> extractBase64Images(String json) {
List<byte[]> res = new ArrayList<>();
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(json);
java.util.Base64.Decoder decoder = java.util.Base64.getDecoder();
if (root.has("data") && root.get("data").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode n : root.get("data")) {
if (n.has("b64_json")) {
String b64 = n.get("b64_json").asText("");
if (!b64.isEmpty()) res.add(decoder.decode(b64));
}
}
}
if (res.isEmpty()) {
if (root.has("images") && root.get("images").has("data") && root.get("images").get("data").isArray()) {
for (com.fasterxml.jackson.databind.JsonNode n : root.get("images").get("data")) {
if (n.has("b64_json")) {
String b64 = n.get("b64_json").asText("");
if (!b64.isEmpty()) res.add(decoder.decode(b64));
}
}
}
}
} catch (Exception e) {
return java.util.Collections.emptyList();
}
return res;
}
private String parseErrorMessage(String json) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(json);
if (root.has("error")) {
com.fasterxml.jackson.databind.JsonNode err = root.get("error");
if (err.has("message")) {
return err.get("message").asText("");
}
if (err.has("code")) {
return err.get("code").asText("");
}
}
} catch (Exception ignore) {
}
return null;
}
}
浙公网安备 33010602011771号