fix: 超管作品详情分页与配音页、配音 API pageNum 对齐

- content-review 详情 VO 增加 pages;作品管理/作品审核抽屉展示与配音预览

- DubbingView:乐读派 voice 返回 0 基与本库 1 基 pageNum 映射,单页请求传 idx

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-16 14:10:58 +08:00
parent 5ae9233afc
commit 41ecbd216f
4 changed files with 86 additions and 28 deletions

View File

@ -13,9 +13,11 @@ import com.lesingle.modules.sys.entity.SysUser;
import com.lesingle.modules.sys.mapper.SysUserMapper;
import com.lesingle.modules.ugc.entity.UgcReviewLog;
import com.lesingle.modules.ugc.entity.UgcWork;
import com.lesingle.modules.ugc.entity.UgcWorkPage;
import com.lesingle.modules.ugc.enums.WorkPublishStatus;
import com.lesingle.modules.ugc.mapper.UgcReviewLogMapper;
import com.lesingle.modules.ugc.mapper.UgcWorkMapper;
import com.lesingle.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -34,6 +36,7 @@ import java.util.stream.Stream;
public class PublicContentReviewService {
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
private final UgcReviewLogMapper ugcReviewLogMapper;
private final SysUserMapper sysUserMapper;
@ -442,6 +445,13 @@ public class PublicContentReviewService {
vo.put("creator", userInfo);
}
}
List<UgcWorkPage> pages = ugcWorkPageMapper.selectList(
new LambdaQueryWrapper<UgcWorkPage>()
.eq(UgcWorkPage::getWorkId, work.getId())
.orderByAsc(UgcWorkPage::getPageNo));
vo.put("pages", pages);
return vo;
}
}

View File

@ -124,10 +124,14 @@
<a-descriptions-item label="发布时间">{{ formatDate(detailData.publishTime) }}</a-descriptions-item>
</a-descriptions>
<!-- 作品描述 -->
<div v-if="detailData.description" class="detail-section">
<h4>作品简介</h4>
<p class="detail-desc">{{ detailData.description }}</p>
<!-- 提交/展示时间与租户端作品详情一致 -->
<div class="detail-submit-time">{{ formatDetailDateTime(detailData) }}</div>
<!-- 作品介绍 -->
<div class="detail-section">
<h4 class="section-title">作品介绍</h4>
<div v-if="detailData.description" class="detail-desc">{{ detailData.description }}</div>
<span v-else class="detail-empty">暂无介绍</span>
</div>
<!-- 标签 -->
@ -138,13 +142,16 @@
</div>
</div>
<!-- 绘本翻页预览 -->
<!-- 作品详情分页图 + 文案 + 配音 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
<h4 class="section-title">作品详情</h4>
<div class="page-preview">
<img v-if="detailData.pages[previewPage]?.imageUrl" :src="detailData.pages[previewPage].imageUrl" class="preview-img" />
<p v-if="detailData.pages[previewPage]?.text" class="preview-text">{{ detailData.pages[previewPage].text }}</p>
<div class="preview-nav">
<div v-if="detailData.pages[previewPage]?.audioUrl" class="preview-audio-wrap">
<audio :src="detailData.pages[previewPage].audioUrl" controls preload="metadata" class="preview-audio" />
</div>
<div v-if="detailData.pages.length > 1" class="preview-nav">
<a-button :disabled="previewPage === 0" size="small" @click="previewPage--">上一页</a-button>
<span>{{ previewPage + 1 }} / {{ detailData.pages.length }}</span>
<a-button :disabled="previewPage === detailData.pages.length - 1" size="small" @click="previewPage++">下一页</a-button>
@ -268,6 +275,13 @@ const columns = [
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
/** 详情抽屉顶部时间:优先提交审核时间,与审核台一致 */
function formatDetailDateTime(d: Record<string, unknown>) {
const raw = (d.submitReviewTime ?? d.createTime) as string | undefined
if (!raw) return '-'
return dayjs(raw).format('YYYY.MM.DD HH:mm')
}
const fetchStats = async () => {
try {
statsRaw.value = await request.get('/content-review/management/stats') as any
@ -444,17 +458,27 @@ $primary: #6366f1;
.cover-thumb { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; }
.cover-empty { width: 48px; height: 64px; background: #f3f4f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #d1d5db; }
.detail-submit-time {
color: #666;
font-size: 14px;
margin: 16px 0 20px;
}
//
.detail-section {
margin-top: 16px;
h4 { font-size: 13px; font-weight: 600; color: #374151; margin: 0 0 8px; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; }
.section-title { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 10px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; white-space: pre-wrap; }
.detail-empty { font-size: 13px; color: #9ca3af; }
}
.preview-section { margin-top: 20px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-audio-wrap { margin: 8px 0 12px; }
.preview-audio { width: 100%; max-height: 40px; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}

View File

@ -158,13 +158,16 @@
</div>
</div>
<!-- 绘本翻页预览 -->
<!-- 绘本翻页预览与作品管理详情一致含配音预览 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
<div class="page-preview">
<img v-if="detailData.pages[previewPage]?.imageUrl" :src="detailData.pages[previewPage].imageUrl" class="preview-img" />
<p v-if="detailData.pages[previewPage]?.text" class="preview-text">{{ detailData.pages[previewPage].text }}</p>
<div class="preview-nav">
<div v-if="detailData.pages[previewPage]?.audioUrl" class="preview-audio-wrap">
<audio :key="`${previewPage}-${detailData.pages[previewPage].audioUrl}`" :src="detailData.pages[previewPage].audioUrl" controls preload="metadata" class="preview-audio" />
</div>
<div v-if="detailData.pages.length > 1" class="preview-nav">
<a-button :disabled="previewPage === 0" size="small" @click="previewPage--">上一页</a-button>
<span>{{ previewPage + 1 }} / {{ detailData.pages.length }}</span>
<a-button :disabled="previewPage === detailData.pages.length - 1" size="small" @click="previewPage++">下一页</a-button>
@ -565,6 +568,8 @@ $primary: #6366f1;
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-audio-wrap { margin: 8px 0 12px; }
.preview-audio { width: 100%; max-height: 40px; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}
.review-actions { margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0ecf9;

View File

@ -15,7 +15,7 @@ export default { name: 'DubbingView' }
<!-- 当前页展示 -->
<div class="page-display">
<div class="page-image-wrap">
<div class="page-badge-pill">{{ currentPage.pageNum === 0 ? '封面' : 'P' + currentPage.pageNum }}</div>
<div class="page-badge-pill">{{ idx === 0 ? '封面' : 'P' + currentPage.pageNum }}</div>
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" />
<div v-else class="page-image placeholder">
<picture-outlined />
@ -222,6 +222,39 @@ function showToast(msg: string) {
setTimeout(() => { toast.value = '' }, 2500)
}
/**
* 乐读派 voicedPagespageNum 0封面
* 本库 pageListpageNum t_ugc_work_page.page_no 1
* 对每个返回项换算目标 pageNum pages 中查找同 pageNum 的分页 写入 audioUrl
*/
function applyVoicedPagesFromApi(voicedPages: { pageNum: number; audioUrl: string }[]) {
voicedPages.forEach(vp => {
const index = pages.value.findIndex(p => (p.pageNum - 1) === vp.pageNum)
if (index !== -1) {
pages.value[index].audioUrl = vp.audioUrl
pages.value[index].localBlob = null
pages.value[index].isAiVoice = true
}
})
// for (const vp of voicedPages) {
// const apiPageNum = Number(vp.pageNum)
// if (Number.isNaN(apiPageNum) || !vp.audioUrl) continue
// const targetPageNum = apiPageNum + 1
// let item = pages.value.find(p => p.pageNum === targetPageNum)
// if (!item) {
// item = pages.value.find(p => p.pageNum === apiPageNum)
// }
// if (item) {
// item.audioUrl = vp.audioUrl
// item.localBlob = null
// item.isAiVoice = true
// }
// }
}
// -- --
const confirmVisible = ref(false)
const confirmTitle = ref('')
@ -387,17 +420,10 @@ function autoAdvance() {
async function voiceSingle() {
voicingSingle.value = true
try {
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: idx.value })
const data = res
if (data.voicedPages?.length) {
for (const vp of data.voicedPages) {
const p = pages.value.find(x => x.pageNum === vp.pageNum)
if (p) {
p.audioUrl = vp.audioUrl
p.localBlob = null
p.isAiVoice = true
}
}
applyVoicedPagesFromApi(data.voicedPages)
showToast('AI 配音成功')
} else {
showToast('配音失败,请重试')
@ -422,14 +448,7 @@ async function voiceAllConfirm() {
const res = await voicePage({ workId: workId.value, voiceAll: true })
const data = res
if (data.voicedPages) {
for (const vp of data.voicedPages) {
const p = pages.value.find(x => x.pageNum === vp.pageNum)
if (p) {
p.audioUrl = vp.audioUrl
p.localBlob = null
p.isAiVoice = true
}
}
applyVoicedPagesFromApi(data.voicedPages)
}
const failed = data.failedPages?.length || 0
showToast(failed > 0 ? `${data.totalSucceeded} 页成功,${failed} 页失败` : '全部 AI 配音完成')