关键词生成故事

前端

<template>
  <div class="story-manager-container cartoon-theme">
    <div class="hero-banner">
      <img src="@/image/story.png" class="hero-image" alt="故事管理" />
    </div>
    <div class="split-layout">
      <div class="left-pane">
        <el-card class="story-card fixed-panel">
      <template #header>
        <div class="card-header">
          <div class="card-title"><span class="card-icon">📚</span><span>儿童故事管理系统</span></div>
        </div>
      </template>
      
      <!-- 故事生成区域 -->
      <el-form :model="generateForm" class="generate-form" label-width="80px">
        <el-form-item label="关键词">
          <el-input v-model="generateForm.keywords" placeholder="请输入故事关键词(例如:魔法森林、勇敢的小兔子等)" />
        </el-form-item>
        <el-form-item>
          <div class="actions-row">
            <el-button type="primary" @click="generateStory" :loading="isGenerating">生成故事</el-button>
            <el-button @click="resetGenerateForm">重置</el-button>
          </div>
        </el-form-item>
      </el-form>
      <div v-if="isGenerating || (genProgress>0 && genProgress<100)" style="margin:8px 0">
        <el-progress :percentage="genProgress" :status="genProgress===100 ? 'success' : ''" :stroke-width="10" />
      </div>
      
      
      
      <!-- 故事列表搜索 -->
      <el-divider>故事列表</el-divider>
      <el-form :model="searchForm" class="search-form" label-width="80px">
        <el-form-item label="关键词">
          <el-input v-model="searchForm.keywords" placeholder="搜索关键词" />
        </el-form-item>
        <el-form-item>
          <div class="actions-row">
            <el-button type="primary" @click="searchStories">搜索</el-button>
            <el-button @click="resetSearchForm">重置</el-button>
          </div>
        </el-form-item>
      </el-form>
      
      <!-- 故事列表 -->
      <el-table 
        v-loading="isLoading"
        element-loading-text="加载中..."
        :data="storyList" 
        style="width: 100%" 
        @selection-change="handleSelectionChange"
        empty-text="暂无数据"
      >
        <el-table-column type="selection" width="55" />
        <el-table-column type="index" :index="indexMethod" label="序号" width="80" />
        <el-table-column prop="title" label="标题" show-overflow-tooltip min-width="200">
          <template #default="scope">
            <div class="story-title-cell" @click="viewStory(scope.row)">
              {{ scope.row.title }}
            </div>
          </template>
        </el-table-column>
        <el-table-column prop="keywords" label="关键词" width="150" show-overflow-tooltip />
        <el-table-column prop="status" label="状态" width="80">
          <template #default="scope">
            <el-tag v-if="scope.row.status === 1">正常</el-tag>
            <el-tag type="danger" v-else>禁用</el-tag>
          </template>
        </el-table-column>
        
            <el-table-column label="操作" width="220" fixed="right">
              <template #default="scope">
                <div class="row-actions">
                  <el-button type="primary" size="small" @click="viewStory(scope.row)">查看</el-button>
                  <el-button type="warning" size="small" @click="editStory(scope.row)">编辑</el-button>
                  <el-button type="danger" size="small" @click="deleteStory(scope.row.id)">删除</el-button>
                </div>
              </template>
            </el-table-column>
      </el-table>
      
      <!-- 分页 -->
      <div class="pagination">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="[10, 20, 50, 100]"
          layout="total, sizes, prev, pager, next, jumper"
          :total="total"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
      
      <!-- 批量操作 -->
      <div class="batch-actions">
        <el-button 
          type="danger" 
          @click="batchDelete" 
          :disabled="selectedStories.length === 0"
        >
          批量删除
        </el-button>
      </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="panelMode==='edit'">
            <el-form :model="editForm" :rules="rules" ref="editFormRef" label-width="80px" class="edit-form">
              <el-form-item label="ID" prop="id">
                <el-input v-model="editForm.id" disabled />
              </el-form-item>
              <el-form-item label="标题" prop="title">
                <el-input v-model="editForm.title" maxlength="100" show-word-limit />
              </el-form-item>
              <el-form-item label="关键词" prop="keywords">
                <el-input v-model="editForm.keywords" maxlength="200" show-word-limit />
              </el-form-item>
              <el-form-item label="内容" prop="body">
                <el-input v-model="editForm.body" type="textarea" :rows="12" maxlength="5000" show-word-limit />
              </el-form-item>
              <el-form-item label="状态" prop="status">
                <el-radio-group v-model="editForm.status">
                  <el-radio :value="1">正常</el-radio>
                  <el-radio :value="0">禁用</el-radio>
                </el-radio-group>
              </el-form-item>
              <div class="edit-actions">
                <el-button @click="handleEditClose">取消</el-button>
                <el-button type="primary" @click="saveEdit" :loading="isLoading">保存</el-button>
              </div>
            </el-form>
          </div>
          <div v-else-if="panelMode==='view' && selectedStory">
            <h2 class="story-detail-title">{{ selectedStory.title }}</h2>
            <div class="story-meta">
              <el-tag class="story-tags" v-for="keyword in selectedStory.keywords?.split(',') || []" :key="keyword">{{ keyword.trim() }}</el-tag>
              <div class="story-status">
                状态:
                <el-tag :type="selectedStory.status === 1 ? 'success' : 'danger'">{{ selectedStory.status === 1 ? '正常' : '禁用' }}</el-tag>
              </div>
              <div class="story-id">ID: {{ selectedStory.id }}</div>
            </div>
            <div class="story-detail-body" v-html="formatStoryBody(selectedStory.body)"></div>
            <div class="edit-actions">
              <el-button type="warning" @click="editStory(selectedStory)">编辑</el-button>
            </div>
          </div>
          <div v-else class="placeholder">
            选择左侧列表中的故事进行查看,或生成后在此编辑并保存
          </div>
        </el-card>
      </div>
    </div>
    
    <!-- 故事详情对话框 -->
      <el-dialog
        v-model="storyDetailVisible"
        title="故事详情"
        width="800px"
        :close-on-click-modal="false"
        :close-on-press-escape="false"
        class="story-detail-dialog"
      >
        <div v-if="selectedStory" class="story-detail-content">
          <h2 class="story-detail-title">{{ selectedStory.title }}</h2>
          <div class="story-meta">
            <el-tag size="small" class="story-tags" v-for="keyword in selectedStory.keywords?.split(',') || []" :key="keyword">
              {{ keyword.trim() }}
            </el-tag>
            <div class="story-status">
              状态: 
              <el-tag size="small" :type="selectedStory.status === 1 ? 'success' : 'danger'">
                {{ selectedStory.status === 1 ? '正常' : '禁用' }}
              </el-tag>
            </div>
            <div class="story-id">ID: {{ selectedStory.id }}</div>
          </div>
          <div class="story-detail-body" v-html="formatStoryBody(selectedStory.body)"></div>
        </div>
        <div v-else class="no-data">正在加载故事详情...</div>
        <template #footer>
          <span class="dialog-footer">
            <el-button @click="storyDetailVisible = false">关闭</el-button>
            <el-button type="primary" @click="editStory(selectedStory)">编辑</el-button>
          </span>
        </template>
      </el-dialog>
    
    <!-- 编辑故事对话框 -->
    <el-dialog
      v-model="editDialogVisible"
      title="编辑故事"
      width="800px"
      :close-on-click-modal="false"
      :close-on-press-escape="false"
      :before-close="handleEditClose"
    >
      <el-form
        :model="editForm"
        :rules="rules"
        ref="editFormRef"
        label-width="80px"
        size="medium"
      >
        <el-form-item label="ID" prop="id">
          <el-input v-model="editForm.id" disabled />
        </el-form-item>
        <el-form-item label="标题" prop="title" :required="true">
          <el-input 
            v-model="editForm.title" 
            placeholder="请输入故事标题" 
            maxlength="100"
            show-word-limit
          />
        </el-form-item>
        <el-form-item label="关键词" prop="keywords">
          <el-input 
            v-model="editForm.keywords" 
            placeholder="请输入关键词,用逗号分隔" 
            maxlength="200"
            show-word-limit
          />
        </el-form-item>
        <el-form-item label="内容" prop="body" :required="true">
          <el-input
            v-model="editForm.body"
            type="textarea"
            :rows="12"
            placeholder="请输入故事内容"
            maxlength="2000"
            show-word-limit
          />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-radio-group v-model="editForm.status">
            <el-radio :label="1">正常</el-radio>
            <el-radio :label="0">禁用</el-radio>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="handleEditClose">取消</el-button>
          <el-button type="primary" @click="saveEdit" :loading="isLoading">保存</el-button>
        </span>
      </template>
    </el-dialog>

    <!-- 语音播放对话框 -->
    <el-dialog
      v-model="soundDialogVisible"
      title="播放语音"
      width="700px"
      :close-on-click-modal="false"
      :close-on-press-escape="false"
    >
      <div v-if="latestSoundRecord">
        <div style="margin-bottom:8px">文件:{{ latestSoundRecord.filePath }}</div>
        <audio v-if="latestSoundUrl" :src="latestSoundUrl" controls style="width:100%"></audio>
      </div>
      <div v-else>暂无语音文件</div>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="soundDialogVisible=false">关闭</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<script>
export default {
  name: 'StoryManager',
  data() {
    return {
      // 生成故事表单
      generateForm: {
        keywords: ''
      },
      // 搜索表单
      searchForm: {
        keywords: ''
      },
      // 编辑表单
      editForm: {
        id: null,
        title: '',
        body: '',
        keywords: '',
        status: 1
      },
      
      // 表单验证规则
      rules: {
        title: [
          { required: true, message: '请输入故事标题', trigger: 'blur' },
          { min: 1, max: 100, message: '标题长度在 1 到 100 个字符', trigger: 'blur' }
        ],
        body: [
          { required: true, message: '请输入故事内容', trigger: 'blur' },
          { min: 1, max: 5000, message: '内容长度在 1 到 5000 个字符', trigger: 'blur' }
        ]
      },
      // 生成的故事
      generatedStory: null,
      // 故事列表
      storyList: [],
      // 选中的故事
      selectedStories: [],
      selectedStory: null,
      // 分页信息
      currentPage: 1,
      pageSize: 10,
      total: 0,
      // 加载状态
      isGenerating: false,
      isLoading: false,
      // 对话框显示状态
      storyDetailVisible: false,
      editDialogVisible: false
      ,soundDialogVisible: false
      ,latestSoundUrl: ''
      ,latestSoundRecord: null
      ,audioLoading: false
      ,panelMode: 'idle'
      ,debug: true
      ,genProgress: 0
      ,genTimer: null
    }
  },
  mounted() {
    // 页面加载时获取故事列表
    this.getStoryList()
  },
  methods: {
    dlog(...args) {
      if (this.debug) console.log('[StoryManager]', ...args)
    },
    indexMethod(i) {
      return (this.currentPage - 1) * this.pageSize + i + 1
    },
    startGenProgress() {
      if (this.genTimer) { clearInterval(this.genTimer); this.genTimer = null }
      this.genProgress = 10
      this.genTimer = setInterval(() => {
        const next = this.genProgress + Math.max(1, Math.floor(Math.random()*7))
        this.genProgress = next >= 90 ? 90 : next
      }, 300)
    },
    endGenProgress(success) {
      if (this.genTimer) { clearInterval(this.genTimer); this.genTimer = null }
      this.genProgress = success ? 100 : 0
    },
    // 生成故事
    generateStory() {
      if (!this.generateForm.keywords.trim()) {
        this.$message.error('请输入关键词')
        return
      }
      
      this.isGenerating = true
      this.dlog('generateStory:start', this.generateForm.keywords)
      this.startGenProgress()
      
      this.$axios.post('/story/preview', {
        keywords: this.generateForm.keywords
      })
      .then(response => {
        if (response.data.code === 200) {
          this.generatedStory = response.data.data
          this.editForm = JSON.parse(JSON.stringify(this.generatedStory))
          this.selectedStory = JSON.parse(JSON.stringify(this.generatedStory))
          this.panelMode = 'edit'
          this.$message.success('故事生成成功')
          this.dlog('generateStory:success', response.data)
          this.endGenProgress(true)
        } else {
          this.$message.error(response.data.msg || '故事生成失败')
          this.dlog('generateStory:fail', response.data)
          this.endGenProgress(false)
        }
      })
      .catch(error => {
        console.error('生成故事失败:', error)
        this.$message.error('生成故事失败,请稍后重试')
        this.dlog('generateStory:error', error)
        this.endGenProgress(false)
      })
      .finally(() => {
        this.isGenerating = false
        this.dlog('generateStory:end')
      })
    },
    
    // 获取故事列表
    getStoryList() {
      this.isLoading = true
      
      const params = {
        pageNum: this.currentPage,
        pageSize: this.pageSize,
        param: {
          keywords: this.searchForm.keywords
        }
      }
      this.dlog('getStoryList:start', params)
      
      this.$axios.post('/story/list', params)
      .then(response => {
        this.isLoading = false
        if (response.data.code === 200) {
          this.storyList = response.data.data || []
          this.total = response.data.total || 0
          this.dlog('getStoryList:success', { total: this.total, listCount: this.storyList.length })
        } else {
          this.$message.error(response.data.msg || '获取故事列表失败')
          this.storyList = []
          this.total = 0
          this.dlog('getStoryList:fail', response.data)
        }
      })
      .catch(error => {
        this.isLoading = false
        console.error('获取故事列表失败:', error)
        this.$message.error('获取故事列表失败,请稍后重试')
        this.storyList = []
        this.total = 0
        this.dlog('getStoryList:error', error)
      })
    },
    
    // 搜索故事
    searchStories() {
      this.currentPage = 1
      this.dlog('searchStories', this.searchForm)
      this.getStoryList()
    },
    
    // 查看故事详情
    viewStory(row) {
      this.selectedStory = JSON.parse(JSON.stringify(row))
      this.storyDetailVisible = false
      this.panelMode = 'view'
      this.dlog('viewStory', row)
    },
    
    // 格式化故事内容,将换行符转换为<br>
    formatStoryBody(body) {
      if (!body) return ''
      return body.replace(/\n/g, '<br>').replace(/\n\n/g, '<br><br>')
    },
    
    // 编辑故事
      editStory(row) {
        if (!row) return
        this.editForm = JSON.parse(JSON.stringify(row))
        this.editDialogVisible = false
        this.panelMode = 'edit'
        this.dlog('editStory', row)
      },
      
      // 保存编辑
      saveEdit() {
        this.$refs.editFormRef.validate((valid) => {
          if (valid) {
            this.isLoading = true
            const isNew = !this.editForm.id
            if (isNew && !this.editForm.userId) {
              const stored = localStorage.getItem('auth_user') || sessionStorage.getItem('auth_user')
              const user = stored ? JSON.parse(stored) : null
              if (!user || !user.id) { this.$message.error('请先登录'); this.isLoading = false; return }
              this.editForm.userId = user.id
            }
            const req = isNew ? this.$axios.post('/story/save', this.editForm) : this.$axios.put('/story/update', this.editForm)
            this.dlog('saveEdit:start', { isNew, payload: this.editForm })
            req
              .then(response => {
                this.isLoading = false
                if (response.data.code === 200) {
                  this.$message.success('保存成功')
                  this.editDialogVisible = false
                  const saved = response.data.data || this.editForm
                  this.selectedStory = JSON.parse(JSON.stringify(saved))
                  this.panelMode = 'view'
                  this.getStoryList()
                  this.dlog('saveEdit:success', response.data)
                } else {
                  this.$message.error(response.data.msg || '保存失败')
                  this.dlog('saveEdit:fail', response.data)
                }
              })
              .catch(error => {
                this.isLoading = false
                console.error('保存故事失败:', error)
                this.$message.error('保存失败,请稍后重试')
                this.dlog('saveEdit:error', error)
              })
          }
        })
      },
      
      // 删除故事
      deleteStory(id) {
        if (!id) return
        this.dlog('deleteStory:start', id)
        this.$confirm('确定要删除这个故事吗?此操作不可撤销!', '警告', {
          confirmButtonText: '确定删除',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          this.isLoading = true
          this.$axios.delete(`/story/${id}`)
            .then(response => {
              this.isLoading = false
              if (response.data.code === 200) {
                this.$message.success('删除成功')
                this.getStoryList()
                this.dlog('deleteStory:success', id)
              } else {
                this.$message.error(response.data.msg || '删除失败')
                this.dlog('deleteStory:fail', response.data)
              }
            })
            .catch(error => {
              this.isLoading = false
              console.error('删除故事失败:', error)
              this.$message.error('删除失败,请稍后重试')
              this.dlog('deleteStory:error', error)
            })
        }).catch(() => {
          this.$message.info('已取消删除')
        })
      },
      
      // 批量删除
      batchDelete() {
        if (this.selectedStories.length === 0) {
          this.$message.warning('请选择要删除的故事')
          return
        }
        
        const ids = this.selectedStories.map(story => story.id)
        this.dlog('batchDelete:start', ids)
        this.$confirm(`确定要删除选中的 ${this.selectedStories.length} 个故事吗?此操作不可撤销!`, '警告', {
          confirmButtonText: '确定删除',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          this.isLoading = true
          this.$axios.post('/story/batch-delete', ids)
            .then(response => {
              this.isLoading = false
              if (response.data.code === 200) {
                this.$message.success(`成功删除 ${this.selectedStories.length} 个故事`)
                this.selectedStories = []
                this.getStoryList()
                this.dlog('batchDelete:success')
              } else {
                this.$message.error(response.data.msg || '批量删除失败')
                this.dlog('batchDelete:fail', response.data)
              }
            })
            .catch(error => {
              this.isLoading = false
              console.error('批量删除故事失败:', error)
              this.$message.error('批量删除失败,请稍后重试')
              this.dlog('batchDelete:error', error)
            })
        }).catch(() => {
          this.$message.info('已取消删除')
        })
      },
    
    // 处理选择变化
    handleSelectionChange(val) {
      this.selectedStories = val
      this.dlog('selectionChange', val)
    },
    
    // 处理编辑对话框关闭
      handleEditClose() {
      this.$refs.editFormRef?.resetFields()
      this.editDialogVisible = false
      this.panelMode = 'idle'
      this.dlog('editClose')
      },
    
    // 分页大小变化
    handleSizeChange(val) {
      this.pageSize = val
      this.getStoryList()
      this.dlog('pageSizeChange', val)
    },
    
    // 当前页变化
    handleCurrentChange(val) {
      this.currentPage = val
      this.getStoryList()
      this.dlog('currentPageChange', val)
    },
    
    // 重置生成表单
    resetGenerateForm() {
      this.generateForm.keywords = ''
      this.generatedStory = null
    },
    
    // 重置搜索表单
    resetSearchForm() {
      this.searchForm.keywords = ''
      this.currentPage = 1
      this.getStoryList()
    }
  }
  ,generateSound(row) {
    if (!row || !row.id) return
    this.audioLoading = true
    this.dlog('generateSound:start', row.id)
    this.$axios.post('/sound/generate', { storyId: row.id }, { timeout: 180000 })
      .then(res => {
        this.audioLoading = false
        if (res.data.code === 200) {
          const s = res.data.data
          this.latestSoundRecord = s
          this.latestSoundUrl = s && s.id ? `/sound/file/${s.id}` : ''
          this.soundDialogVisible = true
          this.$message.success('语音生成成功')
          this.dlog('generateSound:success', s)
        } else {
          this.$message.error(res.data.msg || '生成失败')
          this.dlog('generateSound:fail', res.data)
        }
      })
      .catch(err => {
        this.audioLoading = false
        const isTimeout = err?.code === 'ECONNABORTED' || /timeout/i.test(err?.message || '')
        const msg = isTimeout ? '请求超时,请稍后重试或增大超时' : (err?.response?.data?.msg || '生成失败')
        this.$message.error(msg)
        this.dlog('generateSound:error', err)
      })
  }
  ,playLatest(row) {
    if (!row || !row.id) return
    this.audioLoading = true
    this.dlog('playLatest:start', row.id)
    this.$axios.get(`/sound/by-story/${row.id}`)
      .then(res => {
        this.audioLoading = false
        if (res.data.code === 200) {
          const list = res.data.data || []
          if (list.length === 0) {
            this.$message.info('暂无语音文件')
            this.dlog('playLatest:empty')
            return
          }
          const latest = list.reduce((a,b) => (a.id > b.id ? a : b))
          this.latestSoundRecord = latest
          this.latestSoundUrl = latest && latest.id ? `/sound/file/${latest.id}` : ''
          this.soundDialogVisible = true
          this.dlog('playLatest:success', latest)
        } else {
          this.$message.error(res.data.msg || '查询失败')
          this.dlog('playLatest:fail', res.data)
        }
      })
      .catch(() => {
        this.audioLoading = false
        this.$message.error('查询失败')
        this.dlog('playLatest:error')
      })
  }
}
</script>

<style scoped>
.page-hero { background: linear-gradient(90deg,#fff3e0,#ffe0b2); border: 1px solid #fde0a6; border-radius: 16px; padding: 18px; box-shadow: 0 6px 16px rgba(255,143,0,0.12); margin-bottom: 16px; }
.hero-story { background: linear-gradient(90deg,#e3f2fd,#bbdefb); border-color: #bbdefb; }
.hero-banner { margin-bottom: 16px; height: 180px; border-radius: 16px; overflow: hidden; box-shadow: 0 6px 16px rgba(33,150,243,0.12); border: 1px solid #bbdefb; }
.hero-image { width: 100%; height: 100%; display: block; object-fit: cover; }
.hero-title { font-size: 22px; font-weight: 700; color: #3e2723; }
.hero-sub { color: #6d4c41; margin-top: 6px; }
.story-manager-container {
  padding: 20px;
}

.split-layout { display: flex; gap: 16px; align-items: flex-start; }
.left-pane { flex: 1; min-width: 420px; }
.right-pane { width: 48%; }
.edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.placeholder { color: #909399; padding: 20px; text-align: center; }
.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; }

.story-card {
  margin-bottom: 20px;
}

.card-header {
  display: flex;
  justify-content: space-between;
  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); }

.generate-form,
.search-form,
.edit-form {
  margin-bottom: 20px;
}

.generated-result {
  background-color: #f5f7fa;
  padding: 15px;
  border-radius: 4px;
  margin-bottom: 20px;
}

.story-title {
  color: #303133;
  margin-bottom: 10px;
}

.story-title-cell {
  cursor: pointer;
  color: #409eff;
}

.story-title-cell:hover {
  color: #66b1ff;
  text-decoration: underline;
}

.story-content {
    color: #606266;
    line-height: 1.8;
    white-space: pre-wrap;
  }

  /* 故事详情对话框样式 */
  .story-detail-dialog .el-dialog__header {
    padding: 20px 25px 15px;
  }
  
  .story-detail-dialog .el-dialog__body {
    padding: 0 25px 20px;
    max-height: 500px;
    overflow-y: auto;
  }
  
  .story-detail-content {
    padding: 10px 0;
  }
  
  .story-detail-title {
    font-size: 20px;
    font-weight: 600;
    color: #303133;
    margin: 0 0 15px 0;
    text-align: center;
  }
  
  .story-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    align-items: center;
    margin-bottom: 20px;
    padding-bottom: 15px;
    border-bottom: 1px solid #ebeef5;
  }
  
  .story-tags {
    margin-right: 5px;
  }
  
  .story-id {
    color: #909399;
    font-size: 12px;
    margin-left: auto;
  }
  
  .story-detail-body {
    font-size: 14px;
    line-height: 1.8;
    color: #606266;
    white-space: pre-wrap;
  }
  
.no-data {
  text-align: center;
  color: #909399;
  padding: 40px 0;
}

.pagination {
  margin-top: 20px;
  display: flex;
  justify-content: flex-end;
}

.batch-actions {
  margin-top: 10px;
}

.dialog-footer {
  text-align: right;
}
.cartoon-theme { background: url('@/image/storyBG.png') center/cover no-repeat; }
</style>
.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,#fff3e0,#ffe0b2); 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; }

后端调用大模型——豆包

package com.example.demo.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.util.*;

/**
 * API调用服务,用于与外部大语言模型API交互生成故事内容
 * 
 * @author qi
 * @since 2025-03-13
 */
@Service
public class ApiService {

    @Autowired
    private RestTemplate restTemplate;

    // API基础URL
    private static final String API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions";
    
    // API密钥
    private static final String API_KEY = "";
    
    // 使用的模型
    private static final String MODEL = "doubao-seed-1-6-251015";

    /**
     * 根据关键词生成故事内容
     * 
     * @param keywords 关键词
     * @return 包含标题和正文的Map
     */
    public Map<String, String> generateStoryByKeywords(String keywords) {
        try {
            // 构建请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + API_KEY);
            
            // 构建请求体
            Map<String, Object> requestBody = new HashMap<>();
            requestBody.put("model", MODEL);
            requestBody.put("max_completion_tokens", 1600);
            requestBody.put("reasoning_effort", "medium");
            
            // 构建messages数组,加入system指令提高稳定性
            List<Map<String, Object>> messages = new ArrayList<>();
            Map<String, Object> sysMsg = new HashMap<>();
            sysMsg.put("role", "system");
            List<Map<String, Object>> sysContent = new ArrayList<>();
            Map<String, Object> sysText = new HashMap<>();
            sysText.put("type", "text");
            sysText.put("text", "你是儿童故事生成助手。严格遵循输出格式要求,不得添加额外解释或说明。");
            sysContent.add(sysText);
            sysMsg.put("content", sysContent);
            messages.add(sysMsg);

            Map<String, Object> message = new HashMap<>();
            message.put("role", "user");
            
            // 构建content数组(只包含text部分,因为我们不需要图像)
            List<Map<String, Object>> content = new ArrayList<>();
            Map<String, Object> textContent = new HashMap<>();
            textContent.put("type", "text");
            
            // 构建提示词,要求生成标题和正文
            String prompt = String.format(
                "请基于关键词'%s'创作一个儿童故事,并仅以JSON格式输出:{\\\"title\\\":\\\"[故事标题]\\\",\\\"body\\\":\\\"[故事内容]\\\"}。" +
                "要求:正文字数在300到600字之间,结构完整(开端/发展/结尾),语言通俗积极,适合儿童阅读;不要输出除该JSON外的任何内容。",
                keywords
            );
            textContent.put("text", prompt);
            
            content.add(textContent);
            message.put("content", content);
            messages.add(message);
            
            requestBody.put("messages", messages);
            
            // 创建请求实体
            HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);
            
            // 发送请求
            ResponseEntity<String> response = restTemplate.postForEntity(API_URL, requestEntity, String.class);
            
            if (response.getStatusCode().is2xxSuccessful()) {
                ObjectMapper mapper = new ObjectMapper();
                JsonNode rootNode = mapper.readTree(response.getBody());
                JsonNode choices = rootNode.path("choices");
                JsonNode messageNode = choices.get(0).path("message");
                JsonNode contentNode = messageNode.path("content");
                String generatedContent = "";
                if (contentNode.isArray()) {
                    StringBuilder sb = new StringBuilder();
                    for (JsonNode node : contentNode) {
                        if (node.has("text")) {
                            sb.append(node.path("text").asText(""));
                        }
                        if (node.has("output_text")) {
                            sb.append(node.path("output_text").asText(""));
                        }
                    }
                    generatedContent = sb.toString();
                } else {
                    // 某些版本可能返回字符串或对象,尽量提取文本
                    if (contentNode.has("text")) {
                        generatedContent = contentNode.path("text").asText("");
                    } else if (contentNode.has("output_text")) {
                        generatedContent = contentNode.path("output_text").asText("");
                    } else {
                        generatedContent = contentNode.asText("");
                    }
                }
                // 兼容直接位于 message 的 output_text 字段
                if (generatedContent == null || generatedContent.trim().isEmpty()) {
                    String ot = messageNode.path("output_text").asText("");
                    if (ot != null && !ot.trim().isEmpty()) {
                        generatedContent = ot;
                    }
                }
                Map<String, String> jsonParsed = tryParseJson(generatedContent);
                if (jsonParsed != null) {
                    // 若解析到JSON格式,直接返回
                    return jsonParsed;
                }
                return parseStoryContent(generatedContent);
            } else {
                throw new RuntimeException("API调用失败: " + response.getStatusCodeValue());
            }
        } catch (Exception e) {
            // 记录错误并返回默认故事
            e.printStackTrace();
            Map<String, String> defaultStory = new HashMap<>();
            defaultStory.put("title", "默认故事标题 - " + keywords);
            String body = "由于API调用失败,这是一个基于关键词'" + keywords + "'的默认故事内容。请检查API连接和密钥配置。";
            int limit = 2000;
            if (body.length() > limit) {
                body = body.substring(0, limit);
            }
            defaultStory.put("body", body);
            return defaultStory;
        }
    }
    
    /**
     * 解析API返回的故事内容,提取标题和正文
     * 
     * @param content API返回的完整内容
     * @return 包含标题和正文的Map
     */
    private Map<String, String> parseStoryContent(String content) {
        Map<String, String> result = new HashMap<>();
        
        try {
            String text = content == null ? "" : content.trim();
            String title = null;
            String body = null;
            // 兼容 "标题:[xxx]\n正文:[yyy]" 或不带方括号场景
            java.util.regex.Pattern p = java.util.regex.Pattern.compile("标题\\s*[::]\\s*\\[?(.*?)\\]?\\s*正文\\s*[::]\\s*(.*)", java.util.regex.Pattern.DOTALL);
            java.util.regex.Matcher m = p.matcher(text);
            if (m.find()) {
                title = m.group(1).trim();
                body = m.group(2).trim();
            } else {
                int idx = text.indexOf('\n');
                if (idx > 0 && idx < 50) {
                    title = text.substring(0, idx).replaceFirst("^标题[::]?", "").trim();
                    body = text.substring(idx + 1).trim();
                } else {
                    title = "错误";
                    body = text;
                }
            }
            if (body == null) body = "";
            // 若正文为空,使用关键词构造一个兜底文本,避免前端出现空白
            if (body.isBlank()) {
                body = "这是一个故事草稿,正文暂缺。请稍后重试或编辑补充正文。";
            }
            result.put("title", title);
            result.put("body", body);
        } catch (Exception e) {
            result.put("title", "解析失败的故事");
            String body = content == null ? "" : content;
            int limit = 2000;
            if (body.length() > limit) {
                body = body.substring(0, limit);
            }
            result.put("body", body);
        }
        
        return result;
    }

    // 优先按JSON解析,减少格式不一致导致的空正文
    private Map<String, String> tryParseJson(String text) {
        if (text == null) return null;
        String s = text.trim();
        if (!(s.startsWith("{") && s.endsWith("}"))) return null;
        try {
            ObjectMapper mapper = new ObjectMapper();
            JsonNode node = mapper.readTree(s);
            String title = node.path("title").asText("");
            String body = node.path("body").asText("");
            if (!title.isBlank() && !body.isBlank()) {
                Map<String, String> map = new HashMap<>();
                map.put("title", title.trim());
                map.put("body", body.trim());
                return map;
            }
        } catch (Exception ignore) {}
        return null;
    }
}
posted @ 2025-10-16 22:30  QixunQiu  阅读(8)  评论(0)    收藏  举报