Browse Source

feat(文章音频): 添加音频构建状态功能

实现文章和课程页面的音频构建状态跟踪功能,包括:
1. 添加内容哈希码字段用于标识唯一内容
2. 实现音频构建状态查询接口
3. 在前端展示构建进度状态
4. 添加手动触发音频生成功能
5. 优化长文本TTS任务的并发控制

新增HTML工具类提供内容哈希计算和HTML清理功能
master
主管理员 1 month ago
parent
commit
142486c4a4
14 changed files with 900 additions and 362 deletions
  1. +9
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java
  2. +15
    -2
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java
  3. +214
    -168
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java
  4. +244
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/util/HtmlUtils.java
  5. +59
    -19
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/controller/AppletArticleController.java
  6. +7
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/entity/AppletArticle.java
  7. +43
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/controller/AppletCoursePageController.java
  8. +4
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/entity/AppletCoursePage.java
  9. +9
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.api.ts
  10. +18
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.data.ts
  11. +28
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePageList.vue
  12. +18
    -0
      jeecgboot-vue3/src/views/applet/article/AppletArticle.api.ts
  13. +6
    -0
      jeecgboot-vue3/src/views/applet/article/AppletArticle.data.ts
  14. +226
    -173
      jeecgboot-vue3/src/views/applet/article/AppletArticleList.vue

+ 9
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java View File

@ -268,4 +268,13 @@ public class AppletApiTTSController {
} }
} }
@IgnoreAuth
@Operation(summary = "获取音频构建状态", description = "根据内容哈希码获取音频构建状态")
@GetMapping(value = "/getAudioBuildStatus")
public Result<Map<String, Long>> getAudioBuildStatus(
@Parameter(description = "内容哈希码", required = true) String contentHashcode) {
Map<String, Long> status = appletApiTTService.getAudioBuildStatus(contentHashcode);
return Result.OK(status);
}
} }

+ 15
- 2
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java View File

@ -7,6 +7,7 @@ import org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
public interface AppletApiTTService { public interface AppletApiTTService {
/** /**
@ -26,12 +27,17 @@ public interface AppletApiTTService {
/** /**
* 创建长文本TTS任务异步如已在生成中则直接返回任务信息 * 创建长文本TTS任务异步如已在生成中则直接返回任务信息
*/ */
String createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl);
CompletableFuture<String> createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl);
/** /**
* 为文章内容遍历所有启用音色创建长文本TTS任务异步 * 为文章内容遍历所有启用音色创建长文本TTS任务异步
*/ */
void generateLongTextForArticleContentAllTimbres(String content);
String generateLongTextForArticleContentAllTimbres(String content);
/**
* 为课程页面内容遍历所有启用音色创建长文本TTS任务异步
*/
String generateLongTextForCoursePageContentAllTimbres(String pageId);
/** /**
* 按页面创建长文本TTS任务异步仅接收页面ID与音色ID * 按页面创建长文本TTS任务异步仅接收页面ID与音色ID
@ -60,5 +66,12 @@ public interface AppletApiTTService {
Map<Integer, Object> selectMapByHtml(String content); Map<Integer, Object> selectMapByHtml(String content);
/**
* 根据内容哈希码获取音频构建状态
* @param contentHashcode 内容哈希码
* @return Map包含total(总数)和completed(已完成数)
*/
Map<String, Long> getAudioBuildStatus(String contentHashcode);
} }

+ 214
- 168
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java View File

@ -1,6 +1,5 @@
package org.jeecg.modules.applet.service.impl; package org.jeecg.modules.applet.service.impl;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
@ -33,11 +32,16 @@ import org.jeecg.modules.demo.appletTtsTimbre.service.IAppletTtsTimbreService;
import org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache; import org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache;
import org.jeecg.modules.demo.appletTtsCache.service.IAppletTtsCacheService; import org.jeecg.modules.demo.appletTtsCache.service.IAppletTtsCacheService;
import org.jeecg.modules.applet.util.AudioDurationUtil; import org.jeecg.modules.applet.util.AudioDurationUtil;
import org.jeecg.modules.applet.util.HtmlUtils;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@ -69,6 +73,109 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
@Autowired @Autowired
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
// 长文本TTS并发控制 - 滑动窗口限流器
private final ConcurrentLinkedQueue<Long> requestTimestamps = new ConcurrentLinkedQueue<>();
private final AtomicInteger currentRequests = new AtomicInteger(0);
private static final int MAX_REQUESTS_PER_SECOND = 10;
private static final long WINDOW_SIZE_MS = 1000L; // 1秒窗口
/**
* 长文本TTS请求限流控制
* 使用滑动窗口算法限制每秒最多10个请求
* @return true表示可以继续请求false表示需要等待
*/
private boolean acquireLongTextTtsPermit() {
long currentTime = System.currentTimeMillis();
// 清理过期的时间戳超过1秒的请求记录
while (!requestTimestamps.isEmpty()) {
Long oldestTime = requestTimestamps.peek();
if (oldestTime != null && currentTime - oldestTime > WINDOW_SIZE_MS) {
requestTimestamps.poll();
currentRequests.decrementAndGet();
} else {
break;
}
}
// 检查当前窗口内的请求数量
if (currentRequests.get() < MAX_REQUESTS_PER_SECOND) {
requestTimestamps.offer(currentTime);
currentRequests.incrementAndGet();
return true;
}
return false;
}
/**
* 等待获取长文本TTS请求许可
* 如果当前请求数已达上限则等待直到可以发送请求
*/
private void waitForLongTextTtsPermit() {
while (!acquireLongTextTtsPermit()) {
try {
// 等待100毫秒后重试
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("长文本TTS限流等待被中断: {}", e.getMessage());
break;
}
}
}
/**
* 创建腾讯云TTS客户端
* @return TtsClient实例
*/
private TtsClient createTtsClient() {
// 创建认证对象
Credential cred = new Credential(secretId, secretKey);
// 实例化一个http选项
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");
// 实例化一个client选项
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象
return new TtsClient(cred, "", clientProfile);
}
/**
* 设置TTS请求的通用参数
* @param speed 语速
* @param voiceType 音色类型
* @param volume 音量
* @param codec 编码格式
*/
private void setCommonTtsParams(Object request, Float speed, Integer voiceType, Float volume, String codec) {
try {
// 使用反射设置通用参数
if (speed != null) {
request.getClass().getMethod("setSpeed", Float.class).invoke(request, speed);
}
if (voiceType != null) {
request.getClass().getMethod("setVoiceType", Long.class).invoke(request, voiceType.longValue());
}
if (volume != null) {
request.getClass().getMethod("setVolume", Float.class).invoke(request, volume);
}
String codecValue = (codec != null && !codec.isEmpty()) ? codec : "wav";
request.getClass().getMethod("setCodec", String.class).invoke(request, codecValue);
// 设置其他默认参数
request.getClass().getMethod("setModelType", Long.class).invoke(request, 1L);
request.getClass().getMethod("setPrimaryLanguage", Long.class).invoke(request, 2L);
request.getClass().getMethod("setSampleRate", Long.class).invoke(request, 16000L);
} catch (Exception e) {
log.warn("设置TTS请求参数时发生异常: {}", e.getMessage());
}
}
public TtsVo textToVoice(String text, Float speed, Integer voiceType, Float volume, String codec) { public TtsVo textToVoice(String text, Float speed, Integer voiceType, Float volume, String codec) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
@ -107,19 +214,8 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
// 2. 缓存未命中调用腾讯云TTS接口生成音频 // 2. 缓存未命中调用腾讯云TTS接口生成音频
log.info("TTS缓存未命中,调用腾讯云接口生成音频"); log.info("TTS缓存未命中,调用腾讯云接口生成音频");
// 创建认证对象
Credential cred = new Credential(secretId, secretKey);
// 实例化一个http选项可选的没有特殊需求可以跳过
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");
// 实例化一个client选项可选的没有特殊需求可以跳过
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象
TtsClient client = new TtsClient(cred, "", clientProfile);
// 创建TTS客户端
TtsClient client = createTtsClient();
// 实例化一个请求对象 // 实例化一个请求对象
TextToVoiceRequest req = new TextToVoiceRequest(); TextToVoiceRequest req = new TextToVoiceRequest();
@ -128,26 +224,8 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
req.setText(text); req.setText(text);
req.setSessionId(UUID.randomUUID().toString()); req.setSessionId(UUID.randomUUID().toString());
// 设置可选参数
if (speed != null) {
req.setSpeed(speed);
}
if (voiceType != null) {
req.setVoiceType(voiceType.longValue());
}
if (volume != null) {
req.setVolume(volume);
}
if (codec != null && !codec.isEmpty()) {
req.setCodec(codec);
} else {
req.setCodec("wav"); // 默认wav格式
}
// 设置其他默认参数
req.setModelType(1L); // 默认模型
req.setPrimaryLanguage(2L); // 中文
req.setSampleRate(16000L); // 16k采样率
// 设置通用参数
setCommonTtsParams(req, speed, voiceType, volume, codec);
// 返回的resp是一个TextToVoiceResponse的实例与请求对象对应 // 返回的resp是一个TextToVoiceResponse的实例与请求对象对应
TextToVoiceResponse resp = client.TextToVoice(req); TextToVoiceResponse resp = client.TextToVoice(req);
@ -308,11 +386,13 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
} }
@Override @Override
public String createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl) {
@Async
public CompletableFuture<String> createLongTextTtsTask(String normalized, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl) {
log.info("开始异步创建长文本TTS任务,文本长度: {}, voiceType: {}", normalized != null ? normalized.length() : 0, voiceType);
try { try {
// 将文章内容去除HTML并计算哈希用于缓存唯一标识
String normalized = stripHtml(text);
String textHash = String.valueOf(normalized.hashCode());
// 计算哈希用于缓存唯一标识
String textHash = HtmlUtils.calculateStringHash(normalized);
// 先检查是否存在生成中的任务相同文本哈希与音色 // 先检查是否存在生成中的任务相同文本哈希与音色
LambdaQueryWrapper<AppletTtsCache> generatingWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<AppletTtsCache> generatingWrapper = new LambdaQueryWrapper<>();
@ -323,7 +403,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
AppletTtsCache generating = appletTtsCacheService.getOne(generatingWrapper); AppletTtsCache generating = appletTtsCacheService.getOne(generatingWrapper);
if (generating != null && generating.getTaskId() != null) { if (generating != null && generating.getTaskId() != null) {
log.info("已有长文本TTS任务正在生成中,直接返回任务ID: {}", generating.getTaskId()); log.info("已有长文本TTS任务正在生成中,直接返回任务ID: {}", generating.getTaskId());
return generating.getTaskId();
return CompletableFuture.completedFuture(generating.getTaskId());
} }
// 再检查是否已有成功缓存 // 再检查是否已有成功缓存
@ -335,30 +415,21 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
AppletTtsCache successCache = appletTtsCacheService.getOne(successWrapper); AppletTtsCache successCache = appletTtsCacheService.getOne(successWrapper);
if (successCache != null && successCache.getTaskId() != null) { if (successCache != null && successCache.getTaskId() != null) {
log.info("长文本TTS已完成,返回任务ID: {}", successCache.getTaskId()); log.info("长文本TTS已完成,返回任务ID: {}", successCache.getTaskId());
return successCache.getTaskId();
return CompletableFuture.completedFuture(successCache.getTaskId());
} }
// 应用并发控制等待获取请求许可
log.info("开始等待长文本TTS请求许可,textHash: {}", textHash);
waitForLongTextTtsPermit();
log.info("获得长文本TTS请求许可,开始调用腾讯云API,textHash: {}", textHash);
// 调用腾讯云异步创建任务 // 调用腾讯云异步创建任务
Credential cred = new Credential(secretId, secretKey);
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
TtsClient client = new TtsClient(cred, "", clientProfile);
TtsClient client = createTtsClient();
CreateTtsTaskRequest req = new CreateTtsTaskRequest(); CreateTtsTaskRequest req = new CreateTtsTaskRequest();
req.setText(text);
if (speed != null) req.setSpeed(speed);
if (voiceType != null) req.setVoiceType(voiceType.longValue());
if (volume != null) req.setVolume(volume);
if (codec != null && !codec.isEmpty()) {
req.setCodec(codec);
} else {
req.setCodec("wav");
}
req.setModelType(1L);
req.setPrimaryLanguage(2L);
req.setSampleRate(16000L);
req.setText(normalized);
setCommonTtsParams(req, speed, voiceType, volume, codec);
// 启用时间戳字幕 // 启用时间戳字幕
try { try {
req.getClass().getMethod("setEnableSubtitle", Boolean.class).invoke(req, Boolean.TRUE); req.getClass().getMethod("setEnableSubtitle", Boolean.class).invoke(req, Boolean.TRUE);
@ -387,7 +458,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
appletTtsCacheService.save(cache); appletTtsCacheService.save(cache);
log.info("长文本TTS任务创建成功,taskId: {},textHash: {}", taskId, textHash); log.info("长文本TTS任务创建成功,taskId: {},textHash: {}", taskId, textHash);
return taskId;
return CompletableFuture.completedFuture(taskId);
} catch (Exception e) { } catch (Exception e) {
log.error("创建长文本TTS任务失败: {}", e.getMessage(), e); log.error("创建长文本TTS任务失败: {}", e.getMessage(), e);
throw new JeecgBootException("创建长文本TTS任务失败: " + e.getMessage()); throw new JeecgBootException("创建长文本TTS任务失败: " + e.getMessage());
@ -397,12 +468,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
@Override @Override
public AppletTtsCache queryLongTextTtsTaskStatus(String taskId) { public AppletTtsCache queryLongTextTtsTaskStatus(String taskId) {
try { try {
Credential cred = new Credential(secretId, secretKey);
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
TtsClient client = new TtsClient(cred, "", clientProfile);
TtsClient client = createTtsClient();
DescribeTtsTaskStatusRequest req = new DescribeTtsTaskStatusRequest(); DescribeTtsTaskStatusRequest req = new DescribeTtsTaskStatusRequest();
req.setTaskId(taskId); req.setTaskId(taskId);
@ -473,70 +539,10 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
if (text == null || text.trim().isEmpty()) { if (text == null || text.trim().isEmpty()) {
throw new JeecgBootException("页面内容为空,无法创建TTS任务"); throw new JeecgBootException("页面内容为空,无法创建TTS任务");
} }
try { try {
// 检查是否存在相同页面与音色的生成中任务
LambdaQueryWrapper<AppletTtsCache> generatingWrapper = new LambdaQueryWrapper<>();
generatingWrapper.eq(AppletTtsCache::getPageId, pageId)
.eq(AppletTtsCache::getVoiceType, voiceType)
.eq(AppletTtsCache::getState, 0);
AppletTtsCache generating = appletTtsCacheService.getOne(generatingWrapper);
if (generating != null && generating.getTaskId() != null) {
log.info("页面长文本TTS任务生成中,返回任务ID: {}", generating.getTaskId());
return generating.getTaskId();
}
// 检查是否已有成功缓存
LambdaQueryWrapper<AppletTtsCache> successWrapper = new LambdaQueryWrapper<>();
successWrapper.eq(AppletTtsCache::getPageId, pageId)
.eq(AppletTtsCache::getVoiceType, voiceType)
.eq(AppletTtsCache::getSuccess, "Y")
.eq(AppletTtsCache::getState, 1);
AppletTtsCache successCache = appletTtsCacheService.getOne(successWrapper);
if (successCache != null && successCache.getTaskId() != null) {
log.info("页面长文本TTS已完成,返回任务ID: {}", successCache.getTaskId());
return successCache.getTaskId();
}
// 创建腾讯云任务
Credential cred = new Credential(secretId, secretKey);
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tts.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
TtsClient client = new TtsClient(cred, "", clientProfile);
CreateTtsTaskRequest req = new CreateTtsTaskRequest();
req.setText(text);
if (voiceType != null) req.setVoiceType(voiceType.longValue());
req.setCodec("wav");
req.setModelType(1L);
req.setPrimaryLanguage(2L);
req.setSampleRate(16000L);
req.setEnableSubtitle(true);
req.setCallbackUrl(TtscallbackUrl);
CreateTtsTaskResponse resp = client.CreateTtsTask(req);
String taskId = resp.getData() != null ? resp.getData().getTaskId() : null;
if (taskId == null || taskId.isEmpty()) {
throw new JeecgBootException("创建页面长文本TTS任务失败,未返回任务ID");
}
// 写入缓存标记生成中
AppletTtsCache cache = new AppletTtsCache();
cache.setTaskId(taskId);
cache.setText(text);
cache.setVoiceType(voiceType);
cache.setPageId(pageId);
cache.setSuccess("N");
cache.setState(0);
cache.setCreateTime(new java.util.Date());
appletTtsCacheService.save(cache);
log.info("页面长文本TTS任务创建成功,taskId: {}", taskId);
return taskId;
return createLongTextTtsTask(text, null, voiceType, null, null, null).get();
} catch (Exception e) { } catch (Exception e) {
log.error("创建页面长文本TTS任务失败: {}", e.getMessage(), e);
log.error("创建页面长文本TTS任务失败,pageId: {}, voiceType: {}, error: {}", pageId, voiceType, e.getMessage(), e);
throw new JeecgBootException("创建页面长文本TTS任务失败: " + e.getMessage()); throw new JeecgBootException("创建页面长文本TTS任务失败: " + e.getMessage());
} }
} }
@ -546,32 +552,6 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
*/ */
@Override @Override
public String extractTextByPageId(String pageId) { public String extractTextByPageId(String pageId) {
// 旧实现按标题content纯文本及重点单词拼接已注释
// AppletCoursePage page = appletCoursePageService.getById(pageId);
// if (page == null) {
// throw new JeecgBootException("未找到页面: " + pageId);
// }
// StringBuilder oldSb = new StringBuilder();
// if (page.getTitle() != null) {
// oldSb.append(page.getTitle()).append("\n");
// }
// if (page.getContent() != null) {
// oldSb.append(stripHtml(page.getContent())).append("\n");
// }
// List<AppletCoursePageWord> oldWords = appletCoursePageWordService
// .lambdaQuery()
// .eq(AppletCoursePageWord::getPageId, pageId)
// .list();
// if (oldWords != null && !oldWords.isEmpty()) {
// for (AppletCoursePageWord w : oldWords) {
// if (w.getWord() != null && !w.getWord().isEmpty()) {
// oldSb.append(w.getWord()).append(". ");
// }
// }
// }
// return oldSb.toString().trim();
// 新实现按页面编辑所定义的 JSON 结构解析页面内容仅提取文本组件
AppletCoursePage page = appletCoursePageService.getById(pageId); AppletCoursePage page = appletCoursePageService.getById(pageId);
if (page == null) { if (page == null) {
throw new JeecgBootException("未找到页面: " + pageId); throw new JeecgBootException("未找到页面: " + pageId);
@ -612,17 +592,6 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
} }
} }
private String stripHtml(String html) {
try {
String noTag = html.replaceAll("<[^>]*>", " ");
noTag = noTag.replace("&nbsp;", " ");
return noTag.replaceAll("\\s+", " ").trim();
} catch (Exception e) {
return html;
}
}
@Override @Override
public boolean handleTtsCallback(String taskId, String audioBase64, Integer sampleRate, String codec, boolean success, String message) { public boolean handleTtsCallback(String taskId, String audioBase64, Integer sampleRate, String codec, boolean success, String message) {
try { try {
@ -679,8 +648,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
public Map<Integer, Object> selectMapByHtml(String content) { public Map<Integer, Object> selectMapByHtml(String content) {
HashMap<Integer, Object> map = new HashMap<>(); HashMap<Integer, Object> map = new HashMap<>();
int i = stripHtml(content).hashCode();
String textHash = String.valueOf(i);
String textHash = HtmlUtils.calculateStringHash(HtmlUtils.stripHtml(content));
List<AppletTtsTimbre> list = appletTtsTimbreService List<AppletTtsTimbre> list = appletTtsTimbreService
.lambdaQuery() .lambdaQuery()
@ -745,8 +713,10 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
} }
@Override @Override
public void generateLongTextForArticleContentAllTimbres(String content) {
public String generateLongTextForArticleContentAllTimbres(String content) {
try { try {
String value = HtmlUtils.stripHtml(content);
String contentHashcode = HtmlUtils.calculateStringHash(value);
List<AppletTtsTimbre> timbres = appletTtsTimbreService List<AppletTtsTimbre> timbres = appletTtsTimbreService
.lambdaQuery() .lambdaQuery()
.eq(AppletTtsTimbre::getStatus, "Y") .eq(AppletTtsTimbre::getStatus, "Y")
@ -755,14 +725,49 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
String callbackUrl = TtscallbackUrl; String callbackUrl = TtscallbackUrl;
for (AppletTtsTimbre timbre : timbres) { for (AppletTtsTimbre timbre : timbres) {
try { try {
createLongTextTtsTask(content, null, timbre.getVoiceType(), null, "wav", callbackUrl);
// 异步调用不等待结果
createLongTextTtsTask(value, null, timbre.getVoiceType(), null, "wav", callbackUrl);
} catch (Exception ex) { } catch (Exception ex) {
log.warn("创建文章内容长文本TTS任务失败 voiceType {}: {}", timbre.getVoiceType(), ex.getMessage()); log.warn("创建文章内容长文本TTS任务失败 voiceType {}: {}", timbre.getVoiceType(), ex.getMessage());
} }
} }
return contentHashcode;
} catch (Exception e) { } catch (Exception e) {
log.error("遍历音色创建文章长文本TTS任务失败: {}", e.getMessage(), e); log.error("遍历音色创建文章长文本TTS任务失败: {}", e.getMessage(), e);
} }
return null;
}
@Override
public String generateLongTextForCoursePageContentAllTimbres(String pageId) {
try {
// 提取页面文本内容
String content = extractTextByPageId(pageId);
if (content == null || content.trim().isEmpty()) {
log.warn("课程页面内容为空,无法生成TTS,pageId: {}", pageId);
return null;
}
String contentHashcode = HtmlUtils.calculateStringHash(content);
List<AppletTtsTimbre> timbres = appletTtsTimbreService
.lambdaQuery()
.eq(AppletTtsTimbre::getStatus, "Y")
.select(AppletTtsTimbre::getVoiceType)
.list();
String callbackUrl = TtscallbackUrl;
for (AppletTtsTimbre timbre : timbres) {
try {
// 异步调用不等待结果
createLongTextTtsTask(content, null, timbre.getVoiceType(), null, "wav", callbackUrl);
} catch (Exception ex) {
log.warn("创建课程页面内容长文本TTS任务失败 pageId {}, voiceType {}: {}", pageId, timbre.getVoiceType(), ex.getMessage());
}
}
return contentHashcode;
} catch (Exception e) {
log.error("遍历音色创建课程页面长文本TTS任务失败,pageId {}: {}", pageId, e.getMessage(), e);
}
return null;
} }
/** /**
@ -828,4 +833,45 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
return duration; return duration;
} }
} }
@Override
public Map<String, Long> getAudioBuildStatus(String contentHashcode) {
Map<String, Long> result = new HashMap<>();
try {
if (contentHashcode == null || contentHashcode.isEmpty()) {
result.put("total", 0L);
result.put("completed", 0L);
return result;
}
// 获取所有启用的音色数量作为总数
List<AppletTtsTimbre> enabledTimbres = appletTtsTimbreService
.lambdaQuery()
.eq(AppletTtsTimbre::getStatus, "Y")
.list();
long total = enabledTimbres.size();
// 查询已完成的TTS任务数量
long completed = appletTtsCacheService
.lambdaQuery()
.eq(AppletTtsCache::getText, contentHashcode)
// .eq(AppletTtsCache::getSuccess, "Y")
// .eq(AppletTtsCache::getState, 1)
.count();
result.put("total", total);
result.put("completed", completed);
log.debug("获取音频构建状态成功,contentHashcode: {}, total: {}, completed: {}",
contentHashcode, total, completed);
} catch (Exception e) {
log.error("获取音频构建状态失败,contentHashcode: {}, error: {}", contentHashcode, e.getMessage(), e);
result.put("total", 0L);
result.put("completed", 0L);
}
return result;
}
} }

+ 244
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/util/HtmlUtils.java View File

@ -0,0 +1,244 @@
package org.jeecg.modules.applet.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.HashMap;
import java.util.Map;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.nio.charset.StandardCharsets;
/**
* HTML工具类
* 提供HTML转文本清理HTML标签等功能
*
* @author JeecgBoot
*/
public class HtmlUtils {
/**
* HTML实体转义映射表
*/
private static final Map<String, String> HTML_ENTITIES = new HashMap<>();
static {
// 常见的HTML实体转义
HTML_ENTITIES.put("&nbsp;", " ");
HTML_ENTITIES.put("&amp;", "&");
HTML_ENTITIES.put("&lt;", "<");
HTML_ENTITIES.put("&gt;", ">");
HTML_ENTITIES.put("&quot;", "\"");
HTML_ENTITIES.put("&#39;", "'");
HTML_ENTITIES.put("&apos;", "'");
HTML_ENTITIES.put("&cent;", "¢");
HTML_ENTITIES.put("&pound;", "£");
HTML_ENTITIES.put("&yen;", "¥");
HTML_ENTITIES.put("&euro;", "€");
HTML_ENTITIES.put("&copy;", "©");
HTML_ENTITIES.put("&reg;", "®");
HTML_ENTITIES.put("&trade;", "™");
HTML_ENTITIES.put("&times;", "×");
HTML_ENTITIES.put("&divide;", "÷");
HTML_ENTITIES.put("&hellip;", "…");
HTML_ENTITIES.put("&mdash;", "—");
HTML_ENTITIES.put("&ndash;", "–");
HTML_ENTITIES.put("&lsquo;", "'");
HTML_ENTITIES.put("&rsquo;", "'");
}
/**
* 计算字符串的安全哈希值
* 使用SHA-256算法避免哈希冲突问题
*
* @param input 输入字符串
* @return 哈希值的十六进制字符串表示
*/
public static String calculateStringHash(String input) {
if (input == null) {
return "";
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
// 将字节数组转换为十六进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
// 如果SHA-256不可用回退到使用字符串长度和简单哈希的组合
return String.valueOf(input.length()) + "_" + Math.abs(input.hashCode());
}
}
/**
* 将HTML转换为纯文本
* 支持过滤scriptstyle标签和HTML实体转义
*
* @param htmlStr HTML字符串
* @return 转换后的纯文本
*/
public static String html2Text(String htmlStr) {
if (htmlStr == null || htmlStr.trim().isEmpty()) {
return "";
}
String textStr = htmlStr;
Pattern p_script, p_style, p_html;
Matcher m_script, m_style, m_html;
try {
// 过滤script标签及其内容
String regEx_script = "<[\\s]*?script[^>]*?>[\\s\\S]*?<[\\s]*?/script[\\s]*?>";
p_script = Pattern.compile(regEx_script, Pattern.CASE_INSENSITIVE);
m_script = p_script.matcher(textStr);
textStr = m_script.replaceAll("");
// 过滤style标签及其内容
String regEx_style = "<[\\s]*?style[^>]*?>[\\s\\S]*?<[\\s]*?/style[\\s]*?>";
p_style = Pattern.compile(regEx_style, Pattern.CASE_INSENSITIVE);
m_style = p_style.matcher(textStr);
textStr = m_style.replaceAll("");
// 过滤所有HTML标签
String regEx_html = "<[^>]+>";
p_html = Pattern.compile(regEx_html, Pattern.CASE_INSENSITIVE);
m_html = p_html.matcher(textStr);
textStr = m_html.replaceAll("");
// 处理HTML实体转义
textStr = decodeHtmlEntities(textStr);
// 处理数字实体转义 &#160; &#8203;
textStr = decodeNumericEntities(textStr);
// 清理多余的空白字符
textStr = textStr.replaceAll("\\s+", " ").trim();
} catch (Exception e) {
System.err.println("Html2Text转换异常: " + e.getMessage());
// 发生异常时返回原始字符串
return htmlStr;
}
return textStr;
}
/**
* 解码HTML实体转义
*
* @param text 包含HTML实体的文本
* @return 解码后的文本
*/
private static String decodeHtmlEntities(String text) {
String result = text;
for (Map.Entry<String, String> entry : HTML_ENTITIES.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
return result;
}
/**
* 解码数字HTML实体转义 &#160; &#8203;
*
* @param text 包含数字HTML实体的文本
* @return 解码后的文本
*/
private static String decodeNumericEntities(String text) {
// 匹配十进制数字实体 &#数字;
Pattern decimalPattern = Pattern.compile("&#(\\d+);");
Matcher decimalMatcher = decimalPattern.matcher(text);
StringBuffer sb = new StringBuffer();
while (decimalMatcher.find()) {
try {
int codePoint = Integer.parseInt(decimalMatcher.group(1));
String replacement = "";
// 处理常见的不可见字符或空白字符
if (codePoint == 160 || codePoint == 8203 || codePoint == 8204 || codePoint == 8205) {
replacement = " "; // 替换为普通空格
} else if (codePoint >= 32 && codePoint <= 126) {
// ASCII可打印字符
replacement = String.valueOf((char) codePoint);
} else if (codePoint > 126) {
// Unicode字符
replacement = new String(Character.toChars(codePoint));
}
decimalMatcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
} catch (Exception e) {
// 如果转换失败保留原始内容
decimalMatcher.appendReplacement(sb, Matcher.quoteReplacement(decimalMatcher.group(0)));
}
}
decimalMatcher.appendTail(sb);
text = sb.toString();
// 匹配十六进制数字实体 &#x数字;
Pattern hexPattern = Pattern.compile("&#x([0-9a-fA-F]+);");
Matcher hexMatcher = hexPattern.matcher(text);
sb = new StringBuffer();
while (hexMatcher.find()) {
try {
int codePoint = Integer.parseInt(hexMatcher.group(1), 16);
String replacement = "";
// 处理常见的不可见字符或空白字符
if (codePoint == 160 || codePoint == 8203 || codePoint == 8204 || codePoint == 8205) {
replacement = " "; // 替换为普通空格
} else if (codePoint >= 32 && codePoint <= 126) {
// ASCII可打印字符
replacement = String.valueOf((char) codePoint);
} else if (codePoint > 126) {
// Unicode字符
replacement = new String(Character.toChars(codePoint));
}
hexMatcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
} catch (Exception e) {
// 如果转换失败保留原始内容
hexMatcher.appendReplacement(sb, Matcher.quoteReplacement(hexMatcher.group(0)));
}
}
hexMatcher.appendTail(sb);
return sb.toString();
}
/**
* 简单的HTML标签清理方法兼容原有的stripHtml方法
*
* @param html HTML字符串
* @return 清理后的文本
*/
public static String stripHtml(String html) {
if (html == null || html.trim().isEmpty()) {
return "";
}
try {
// 移除HTML标签
String noTag = html.replaceAll("<[^>]*>", "");
// 处理常见的HTML实体
noTag = noTag.replace("&nbsp;", " ");
noTag = noTag.replace("&amp;", "&");
noTag = noTag.replace("&lt;", "<");
noTag = noTag.replace("&gt;", ">");
noTag = noTag.replace("&quot;", "\"");
// 清理多余空白
return noTag.replaceAll("\\s+", " ").trim();
} catch (Exception e) {
return html;
}
}
}

+ 59
- 19
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/controller/AppletArticleController.java View File

@ -77,6 +77,11 @@ public class AppletArticleController extends JeecgController<AppletArticle, IApp
QueryWrapper<AppletArticle> queryWrapper = QueryGenerator.initQueryWrapper(appletArticle, req.getParameterMap()); QueryWrapper<AppletArticle> queryWrapper = QueryGenerator.initQueryWrapper(appletArticle, req.getParameterMap());
Page<AppletArticle> page = new Page<AppletArticle>(pageNo, pageSize); Page<AppletArticle> page = new Page<AppletArticle>(pageNo, pageSize);
IPage<AppletArticle> pageList = appletArticleService.page(page, queryWrapper); IPage<AppletArticle> pageList = appletArticleService.page(page, queryWrapper);
for (AppletArticle record : page.getRecords()) {
record.setAudioStatus(appletApiTTService.getAudioBuildStatus(record.getContentHashcode()));
}
return Result.OK(pageList); return Result.OK(pageList);
} }
@ -94,14 +99,16 @@ public class AppletArticleController extends JeecgController<AppletArticle, IApp
appletArticleService.save(appletArticle); appletArticleService.save(appletArticle);
// 触发长文本TTS生成遍历所有启用音色对content进行转换 // 触发长文本TTS生成遍历所有启用音色对content进行转换
try {
String content = appletArticle.getContent();
if (content != null && !content.trim().isEmpty()) {
appletApiTTService.generateLongTextForArticleContentAllTimbres(content);
}
} catch (Exception e) {
log.warn("文章添加后触发长文本TTS失败: {}", e.getMessage());
}
// try {
// String content = appletArticle.getContent();
// if (content != null && !content.trim().isEmpty()) {
// String code = appletApiTTService.generateLongTextForArticleContentAllTimbres(content);
// appletArticle.setContentHashcode(code);
// appletArticleService.updateById(appletArticle);
// }
// } catch (Exception e) {
// log.warn("文章添加后触发长文本TTS失败: {}", e.getMessage());
// }
return Result.OK("添加成功!"); return Result.OK("添加成功!");
} }
@ -117,18 +124,17 @@ public class AppletArticleController extends JeecgController<AppletArticle, IApp
@RequiresPermissions("appletArticle:applet_article:edit") @RequiresPermissions("appletArticle:applet_article:edit")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST}) @RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST})
public Result<String> edit(@RequestBody AppletArticle appletArticle) { public Result<String> edit(@RequestBody AppletArticle appletArticle) {
appletArticleService.updateById(appletArticle);
// 触发长文本TTS生成遍历所有启用音色对content进行转换 // 触发长文本TTS生成遍历所有启用音色对content进行转换
try {
String content = appletArticle.getContent();
if (content != null && !content.trim().isEmpty()) {
appletApiTTService.generateLongTextForArticleContentAllTimbres(content);
}
} catch (Exception e) {
log.warn("文章编辑后触发长文本TTS失败: {}", e.getMessage());
}
// try {
// String content = appletArticle.getContent();
// if (content != null && !content.trim().isEmpty()) {
// String code = appletApiTTService.generateLongTextForArticleContentAllTimbres(content);
// appletArticle.setContentHashcode(code);
// }
// } catch (Exception e) {
// log.warn("文章编辑后触发长文本TTS失败: {}", e.getMessage());
// }
appletArticleService.updateById(appletArticle);
return Result.OK("编辑成功!"); return Result.OK("编辑成功!");
} }
@ -204,4 +210,38 @@ public class AppletArticleController extends JeecgController<AppletArticle, IApp
return super.importExcel(request, response, AppletArticle.class); return super.importExcel(request, response, AppletArticle.class);
} }
/**
* 手动触发音频转换
*
* @param id 文章ID
* @return
*/
@AutoLog(value = "小程序文章-触发音频转换")
@Operation(summary="小程序文章-触发音频转换")
@RequiresPermissions("appletArticle:applet_article:edit")
@GetMapping(value = "/generateAudio")
public Result<String> generateAudio(@RequestParam(name="id",required=true) String id) {
try {
AppletArticle appletArticle = appletArticleService.getById(id);
if (appletArticle == null) {
return Result.error("文章不存在");
}
String content = appletArticle.getContent();
if (content == null || content.trim().isEmpty()) {
return Result.error("文章内容为空,无法生成音频");
}
// 触发长文本TTS生成遍历所有启用音色对content进行转换
String code = appletApiTTService.generateLongTextForArticleContentAllTimbres(content);
appletArticle.setContentHashcode(code);
appletArticleService.updateById(appletArticle);
return Result.OK("音频转换已开始,请稍后查看状态");
} catch (Exception e) {
log.error("触发音频转换失败: {}", e.getMessage(), e);
return Result.error("音频转换失败: " + e.getMessage());
}
}
} }

+ 7
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/entity/AppletArticle.java View File

@ -71,7 +71,14 @@ public class AppletArticle implements Serializable {
@Excel(name = "内容", width = 15) @Excel(name = "内容", width = 15)
@Schema(description = "内容") @Schema(description = "内容")
private java.lang.String content; private java.lang.String content;
/**内容哈希码*/
@Excel(name = "内容哈希码", width = 15)
@Schema(description = "内容哈希码")
private java.lang.String contentHashcode;
@TableField(exist = false) @TableField(exist = false)
private Map<Integer, Object> audios; private Map<Integer, Object> audios;
@TableField(exist = false)
private Map<String, Long> audioStatus;
} }

+ 43
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/controller/AppletCoursePageController.java View File

@ -16,6 +16,7 @@ import org.jeecg.common.system.query.QueryRuleEnum;
import org.jeecg.common.util.oConvertUtils; import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.demo.appletCoursePage.entity.AppletCoursePage; import org.jeecg.modules.demo.appletCoursePage.entity.AppletCoursePage;
import org.jeecg.modules.demo.appletCoursePage.service.IAppletCoursePageService; import org.jeecg.modules.demo.appletCoursePage.service.IAppletCoursePageService;
import org.jeecg.modules.applet.service.AppletApiTTService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
@ -52,6 +53,9 @@ public class AppletCoursePageController extends JeecgController<AppletCoursePage
@Autowired @Autowired
private IAppletCoursePageService appletCoursePageService; private IAppletCoursePageService appletCoursePageService;
@Autowired
private AppletApiTTService appletApiTTService;
/** /**
* 分页列表查询 * 分页列表查询
* *
@ -102,6 +106,16 @@ public class AppletCoursePageController extends JeecgController<AppletCoursePage
@PostMapping(value = "/add") @PostMapping(value = "/add")
public Result<AppletCoursePage> add(@RequestBody AppletCoursePage appletCoursePage) { public Result<AppletCoursePage> add(@RequestBody AppletCoursePage appletCoursePage) {
appletCoursePageService.save(appletCoursePage); appletCoursePageService.save(appletCoursePage);
// 生成TTS音频
// try {
// String code = appletApiTTService.generateLongTextForCoursePageContentAllTimbres(appletCoursePage.getId());
// appletCoursePage.setContentHashcode(code);
// appletCoursePageService.updateById(appletCoursePage);
// } catch (Exception e) {
// log.warn("生成课程页面TTS音频失败,pageId: {}, error: {}", appletCoursePage.getId(), e.getMessage());
// }
return Result.OK(appletCoursePage); return Result.OK(appletCoursePage);
} }
@ -117,6 +131,17 @@ public class AppletCoursePageController extends JeecgController<AppletCoursePage
@RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST}) @RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST})
public Result<String> edit(@RequestBody AppletCoursePage appletCoursePage) { public Result<String> edit(@RequestBody AppletCoursePage appletCoursePage) {
appletCoursePageService.updateById(appletCoursePage); appletCoursePageService.updateById(appletCoursePage);
// 生成TTS音频
// try {
// String code = appletApiTTService.generateLongTextForCoursePageContentAllTimbres(appletCoursePage.getId());
// appletCoursePage.setContentHashcode(code);
// } catch (Exception e) {
// log.warn("生成课程页面TTS音频失败,pageId: {}, error: {}", appletCoursePage.getId(), e.getMessage());
// }
//
// appletCoursePageService.updateById(appletCoursePage);
return Result.OK("编辑成功!"); return Result.OK("编辑成功!");
} }
@ -167,6 +192,24 @@ public class AppletCoursePageController extends JeecgController<AppletCoursePage
return Result.OK(appletCoursePage); return Result.OK(appletCoursePage);
} }
// /**
// * 获取课程页面音频构建状态
// *
// * @param pageId
// * @return
// */
// @Operation(summary="获取课程页面音频构建状态")
// @GetMapping(value = "/getTtsStatus")
// public Result<Map<String, Object>> getTtsStatus(@RequestParam(name="pageId",required=true) String pageId) {
// try {
// Map<String, Object> statusInfo = appletApiTTService.getCoursePageTtsStatus(pageId);
// return Result.OK(statusInfo);
// } catch (Exception e) {
// log.error("获取课程页面音频构建状态失败: {}", e.getMessage(), e);
// return Result.error("获取音频构建状态失败");
// }
// }
/** /**
* 导出excel * 导出excel
* *


+ 4
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/entity/AppletCoursePage.java View File

@ -85,6 +85,10 @@ public class AppletCoursePage implements Serializable {
@Excel(name = "上架", width = 15,replace = {"是_Y","否_N"} ) @Excel(name = "上架", width = 15,replace = {"是_Y","否_N"} )
@Schema(description = "上架") @Schema(description = "上架")
private java.lang.String status; private java.lang.String status;
/**内容哈希码*/
@Excel(name = "内容哈希码", width = 15)
@Schema(description = "内容哈希码")
private java.lang.String contentHashcode;
@TableField(exist = false) @TableField(exist = false)
private List<AppletCoursePageWord> words; private List<AppletCoursePageWord> words;


+ 9
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.api.ts View File

@ -11,6 +11,7 @@ enum Api {
deleteBatch = '/appletCoursePage/appletCoursePage/deleteBatch', deleteBatch = '/appletCoursePage/appletCoursePage/deleteBatch',
importExcel = '/appletCoursePage/appletCoursePage/importExcel', importExcel = '/appletCoursePage/appletCoursePage/importExcel',
exportXls = '/appletCoursePage/appletCoursePage/exportXls', exportXls = '/appletCoursePage/appletCoursePage/exportXls',
getAudioBuildStatus = '/appletApi/tts/getAudioBuildStatus',
} }
/** /**
* api * api
@ -62,3 +63,11 @@ export const saveOrUpdate = (params, isUpdate) => {
let url = isUpdate ? Api.edit : Api.save; let url = isUpdate ? Api.edit : Api.save;
return defHttp.post({url: url, params}); return defHttp.post({url: url, params});
} }
/**
*
* @param contentHashcode
*/
export const getAudioBuildStatus = (contentHashcode) => {
return defHttp.get({url: Api.getAudioBuildStatus, params: { contentHashcode }});
}

+ 18
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.data.ts View File

@ -3,6 +3,7 @@ import {FormSchema} from '/@/components/Table';
import { rules} from '/@/utils/helper/validator'; import { rules} from '/@/utils/helper/validator';
import { render } from '/@/utils/common/renderUtils'; import { render } from '/@/utils/common/renderUtils';
import { getWeekMonthQuarterYear } from '/@/utils'; import { getWeekMonthQuarterYear } from '/@/utils';
import { getAudioBuildStatus } from './AppletCoursePage.api';
//列表数据 //列表数据
export const columns: BasicColumn[] = [ export const columns: BasicColumn[] = [
{ {
@ -41,6 +42,23 @@ export const columns: BasicColumn[] = [
return render.renderSwitch(text, [{text:'是',value:'Y'},{text:'否',value:'N'}]) return render.renderSwitch(text, [{text:'是',value:'Y'},{text:'否',value:'N'}])
}, },
}, },
{
title: '音频构建状态',
align: "center",
dataIndex: 'audioStatus',
customRender: async ({ record }) => {
if (!record.contentHashcode) return '未开始';
try {
const result = await getAudioBuildStatus(record.contentHashcode);
const { total, completed } = result;
if (completed === 0) return '未开始';
if (completed === total) return '已完成';
return `${completed}/${total}`;
} catch (error) {
return '未开始';
}
}
},
]; ];
//查询数据 //查询数据
export const searchFormSchema: FormSchema[] = [ export const searchFormSchema: FormSchema[] = [


+ 28
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePageList.vue View File

@ -30,6 +30,12 @@
</template> </template>
<!--字段回显插槽--> <!--字段回显插槽-->
<template v-slot:bodyCell="{ column, record, index, text }"> <template v-slot:bodyCell="{ column, record, index, text }">
<template v-if="column.dataIndex==='audioStatus'">
<!--音频构建状态显示插槽-->
<span :style="{ color: getAudioStatusColor(record.audioStatus) }">
{{ getAudioStatusText(record.audioStatus) }}
</span>
</template>
</template> </template>
</BasicTable> </BasicTable>
<!-- 表单区域 --> <!-- 表单区域 -->
@ -197,6 +203,28 @@
/**
* 获取音频状态文本
*/
function getAudioStatusText(status) {
if (!status) return '未开始';
const { total, completed } = status;
if (completed === 0) return '未开始';
if (completed === total) return '已完成';
return `${completed}/${total}`;
}
/**
* 获取音频状态颜色
*/
function getAudioStatusColor(status) {
if (!status) return '#999';
const { total, completed } = status;
if (completed === 0) return '#999';
if (completed === total) return '#52c41a';
return '#1890ff';
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>


+ 18
- 0
jeecgboot-vue3/src/views/applet/article/AppletArticle.api.ts View File

@ -11,6 +11,8 @@ enum Api {
deleteBatch = '/appletArticle/appletArticle/deleteBatch', deleteBatch = '/appletArticle/appletArticle/deleteBatch',
importExcel = '/appletArticle/appletArticle/importExcel', importExcel = '/appletArticle/appletArticle/importExcel',
exportXls = '/appletArticle/appletArticle/exportXls', exportXls = '/appletArticle/appletArticle/exportXls',
getAudioBuildStatus = '/appletApi/tts/getAudioBuildStatus',
generateAudio = '/appletArticle/appletArticle/generateAudio',
} }
/** /**
* api * api
@ -62,3 +64,19 @@ export const saveOrUpdate = (params, isUpdate) => {
let url = isUpdate ? Api.edit : Api.save; let url = isUpdate ? Api.edit : Api.save;
return defHttp.post({url: url, params}); return defHttp.post({url: url, params});
} }
/**
*
* @param contentHashcode
*/
export const getAudioBuildStatus = (contentHashcode) => {
return defHttp.get({url: Api.getAudioBuildStatus, params: { contentHashcode }});
}
/**
*
* @param id ID
*/
export const generateAudio = (id) => {
return defHttp.get({url: Api.generateAudio, params: { id }});
}

+ 6
- 0
jeecgboot-vue3/src/views/applet/article/AppletArticle.data.ts View File

@ -3,6 +3,7 @@ import {FormSchema} from '/src/components/Table';
import { rules} from '/src/utils/helper/validator'; import { rules} from '/src/utils/helper/validator';
import { render } from '/src/utils/common/renderUtils'; import { render } from '/src/utils/common/renderUtils';
import { getWeekMonthQuarterYear } from '/src/utils'; import { getWeekMonthQuarterYear } from '/src/utils';
import { getAudioBuildStatus } from './AppletArticle.api';
//列表数据 //列表数据
export const columns: BasicColumn[] = [ export const columns: BasicColumn[] = [
{ {
@ -23,6 +24,11 @@ export const columns: BasicColumn[] = [
return render.renderSwitch(text, [{text:'是',value:'Y'},{text:'否',value:'N'}]) return render.renderSwitch(text, [{text:'是',value:'Y'},{text:'否',value:'N'}])
}, },
}, },
{
title: '音频构建状态',
align:"center",
dataIndex: 'contentHashcode',
},
]; ];
//查询数据 //查询数据
export const searchFormSchema: FormSchema[] = [ export const searchFormSchema: FormSchema[] = [


+ 226
- 173
jeecgboot-vue3/src/views/applet/article/AppletArticleList.vue View File

@ -1,35 +1,48 @@
<template> <template>
<div> <div>
<!--引用表格--> <!--引用表格-->
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<!--插槽:table标题-->
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<!--插槽:table标题-->
<template #tableTitle> <template #tableTitle>
<a-button type="primary" v-auth="'appletArticle:applet_article:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'appletArticle:applet_article:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'appletArticle:applet_article:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-button type="primary" v-auth="'appletArticle:applet_article:add'" @click="handleAdd"
preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'appletArticle:applet_article:exportXls'" preIcon="ant-design:export-outlined"
@click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'appletArticle:applet_article:importExcel'"
preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete">
<Icon icon="ant-design:delete-outlined"></Icon>
删除
</a-menu-item>
</a-menu>
</template>
<a-button v-auth="'appletArticle:applet_article:deleteBatch'">批量操作
<Icon icon="mdi:chevron-down"></Icon>
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete">
<Icon icon="ant-design:delete-outlined"></Icon>
删除
</a-menu-item>
</a-menu>
</template>
<a-button v-auth="'appletArticle:applet_article:deleteBatch'">批量操作
<Icon icon="mdi:chevron-down"></Icon>
</a-button>
</a-dropdown> </a-dropdown>
<!-- 高级查询 --> <!-- 高级查询 -->
<super-query :config="superQueryConfig" @search="handleSuperQuery" /> <super-query :config="superQueryConfig" @search="handleSuperQuery" />
</template> </template>
<!--操作栏-->
<!--操作栏-->
<template #action="{ record }"> <template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)"/>
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template> </template>
<!--字段回显插槽--> <!--字段回显插槽-->
<template v-slot:bodyCell="{ column, record, index, text }"> <template v-slot:bodyCell="{ column, record, index, text }">
<template v-if="column.dataIndex === 'content'">
<!--富文本件字段回显插槽-->
<div v-html="text"></div>
</template>
<template v-if="column.dataIndex === 'contentHashcode'">
<!--音频构建状态显示插槽-->
<span :style="{ color: getAudioStatusColor(record.audioStatus) }">
{{ getAudioStatusText(record.audioStatus) }}
</span>
</template>
</template> </template>
</BasicTable> </BasicTable>
<!-- 表单区域 --> <!-- 表单区域 -->
@ -38,169 +51,209 @@
</template> </template>
<script lang="ts" name="appletArticle-appletArticle" setup> <script lang="ts" name="appletArticle-appletArticle" setup>
import {ref, reactive, computed, unref} from 'vue';
import {BasicTable, useTable, TableAction} from '/@/components/Table';
import {useModal} from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage'
import AppletArticleModal from './components/AppletArticleModal.vue'
import {columns, searchFormSchema, superQuerySchema} from './AppletArticle.data';
import {list, deleteOne, batchDelete, getImportUrl,getExportUrl} from './AppletArticle.api';
import { downloadFile } from '/@/utils/common/renderUtils';
import { useUserStore } from '/@/store/modules/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { getDateByPicker } from '/@/utils';
//
const fieldPickers = reactive({
});
const queryParam = reactive<any>({});
const checkedKeys = ref<Array<string | number>>([]);
const userStore = useUserStore();
const { createMessage } = useMessage();
//model
const [registerModal, {openModal}] = useModal();
//table
const { prefixCls,tableContext,onExportXls,onImportXls } = useListPage({
tableProps:{
title: '小程序文章',
api: list,
columns,
canResize:true,
formConfig: {
//labelWidth: 120,
schemas: searchFormSchema,
autoSubmitOnEnter:true,
showAdvancedButton:true,
fieldMapToNumber: [
],
fieldMapToTime: [
],
},
actionColumn: {
width: 120,
fixed:'right'
},
beforeFetch: (params) => {
if (params && fieldPickers) {
for (let key in fieldPickers) {
if (params[key]) {
params[key] = getDateByPicker(params[key], fieldPickers[key]);
}
}
}
return Object.assign(params, queryParam);
},
},
exportConfig: {
name:"小程序文章",
url: getExportUrl,
params: queryParam,
},
importConfig: {
url: getImportUrl,
success: handleSuccess
},
})
import { ref, reactive, computed, unref } from 'vue';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage'
import AppletArticleModal from './components/AppletArticleModal.vue'
import { columns, searchFormSchema, superQuerySchema } from './AppletArticle.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, generateAudio } from './AppletArticle.api';
import { downloadFile } from '/@/utils/common/renderUtils';
import { useUserStore } from '/@/store/modules/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { getDateByPicker } from '/@/utils';
//
const fieldPickers = reactive({
});
const queryParam = reactive<any>({});
const checkedKeys = ref<Array<string | number>>([]);
const userStore = useUserStore();
const { createMessage } = useMessage();
//model
const [registerModal, { openModal }] = useModal();
//table
const { prefixCls, tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '小程序文章',
api: list,
columns,
canResize: true,
formConfig: {
//labelWidth: 120,
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
fieldMapToNumber: [
],
fieldMapToTime: [
],
},
actionColumn: {
width: 200,
fixed: 'right'
},
beforeFetch: (params) => {
if (params && fieldPickers) {
for (let key in fieldPickers) {
if (params[key]) {
params[key] = getDateByPicker(params[key], fieldPickers[key]);
}
}
}
return Object.assign(params, queryParam);
},
},
exportConfig: {
name: "小程序文章",
url: getExportUrl,
params: queryParam,
},
importConfig: {
url: getImportUrl,
success: handleSuccess
},
})
const [registerTable, {reload},{ rowSelection, selectedRowKeys }] = tableContext
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext
//
const superQueryConfig = reactive(superQuerySchema);
//
const superQueryConfig = reactive(superQuerySchema);
/**
* 高级查询事件
*/
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
});
reload();
}
/**
* 新增事件
*/
function handleAdd() {
openModal(true, {
isUpdate: false,
showFooter: true,
});
/**
* 高级查询事件
*/
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
});
reload();
}
/**
* 新增事件
*/
function handleAdd() {
openModal(true, {
isUpdate: false,
showFooter: true,
});
}
/**
* 编辑事件
*/
function handleEdit(record: Recordable) {
openModal(true, {
record,
isUpdate: true,
showFooter: true,
});
}
/**
* 详情
*/
function handleDetail(record: Recordable) {
openModal(true, {
record,
isUpdate: true,
showFooter: false,
});
}
/**
* 删除事件
*/
async function handleDelete(record) {
await deleteOne({ id: record.id }, handleSuccess);
}
/**
* 批量删除事件
*/
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
/**
* 成功回调
*/
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
/**
* 生成音频事件
*/
async function handleGenerateAudio(record) {
try {
createMessage.loading('正在生成音频,请稍候...', 2);
const result = await generateAudio(record.id);
} catch (error) {
createMessage.error('音频生成失败: ' + error.message);
} }
/**
* 编辑事件
*/
function handleEdit(record: Recordable) {
openModal(true, {
record,
isUpdate: true,
showFooter: true,
});
}
/**
* 详情
}
/**
* 操作栏
*/ */
function handleDetail(record: Recordable) {
openModal(true, {
record,
isUpdate: true,
showFooter: false,
});
}
/**
* 删除事件
*/
async function handleDelete(record) {
await deleteOne({id: record.id}, handleSuccess);
}
/**
* 批量删除事件
*/
async function batchHandleDelete() {
await batchDelete({ids: selectedRowKeys.value}, handleSuccess);
}
/**
* 成功回调
*/
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
/**
* 操作栏
*/
function getTableAction(record){
return [
{
label: '编辑',
onClick: handleEdit.bind(null, record),
auth: 'appletArticle:applet_article:edit'
}
]
}
/**
* 下拉操作栏
*/
function getDropDownAction(record){
return [
{
label: '详情',
onClick: handleDetail.bind(null, record),
}, {
label: '删除',
popConfirm: {
title: '是否确认删除',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
auth: 'appletArticle:applet_article:delete'
}
]
}
function getTableAction(record) {
return [
{
label: '编辑',
onClick: handleEdit.bind(null, record),
auth: 'appletArticle:applet_article:edit'
},
{
label: '生成音频',
onClick: handleGenerateAudio.bind(null, record),
auth: 'appletArticle:applet_article:edit'
}
]
}
/**
* 下拉操作栏
*/
function getDropDownAction(record) {
return [
{
label: '详情',
onClick: handleDetail.bind(null, record),
}, {
label: '删除',
popConfirm: {
title: '是否确认删除',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
auth: 'appletArticle:applet_article:delete'
}
]
}
/**
* 获取音频状态文本
*/
function getAudioStatusText(status) {
if (!status) return '未开始';
const { total, completed } = status;
if (completed === 0) return '未开始';
if (completed === total) return '已完成';
return `${completed}/${total}`;
}
/**
* 获取音频状态颜色
*/
function getAudioStatusColor(status) {
if (!status) return '#999';
const { total, completed } = status;
if (completed === 0) return '#999';
if (completed === total) return '#52c41a';
return '#1890ff';
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
:deep(.ant-picker),:deep(.ant-input-number){
width: 100%;
}
</style>
:deep(.ant-picker),
:deep(.ant-input-number) {
width: 100%;
}
</style>

Loading…
Cancel
Save