通过故事生成插画

前端

<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;
    }
}
posted @ 2025-10-20 14:18  QixunQiu  阅读(7)  评论(0)    收藏  举报