主管理员 3 months ago
parent
commit
c118302d4d
8 changed files with 1018 additions and 216 deletions
  1. +47
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java
  2. +26
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java
  3. +412
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java
  4. +8
    -0
      jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletTtsCache/entity/AppletTtsCache.java
  5. +5
    -3
      jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
  6. +4
    -204
      jeecgboot-vue3/src/views/applet/course-page/AppletCoursePageList.vue
  7. +463
    -0
      jeecgboot-vue3/src/views/applet/course-page/components/ContentEditor.vue
  8. +53
    -9
      jeecgboot-vue3/src/views/applet/course-page/components/MobilePreview.vue

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

@ -18,6 +18,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import java.util.List;
import java.util.Map;
@Slf4j
@Tag(name = "文字转语音", description = "文字转语音")
@ -95,4 +96,50 @@ public class AppletApiTTSController {
return Result.ok(audioData);
}
// @Operation(summary = "创建长文本TTS任务", description = "创建异步长文本语音合成任务。如已有相同文本在生成,则返回已有任务ID")
// @PostMapping(value = "/long/create")
// @IgnoreAuth
// public Result<String> createLongTextTask(
// @Parameter(description = "要转换的文本内容", required = true) @RequestParam("text") String text,
// @Parameter(description = "语速,范围:[-2,6],默认为0") @RequestParam(value = "speed", required = false) Float speed,
// @Parameter(description = "音色ID,默认为0") @RequestParam(value = "voiceType", required = false) Integer voiceType,
// @Parameter(description = "音量大小,范围[-10,10],默认为0") @RequestParam(value = "volume", required = false) Float volume,
// @Parameter(description = "返回音频格式,可取值:wav(默认),mp3,pcm") @RequestParam(value = "codec", required = false) String codec,
// @Parameter(description = "回调地址(可选)") @RequestParam(value = "callbackUrl", required = false) String callbackUrl
// ) {
// String taskId = appletApiTTService.createLongTextTtsTask(text, speed, voiceType, volume, codec, callbackUrl);
// return Result.OK(taskId);
// }
// @Operation(summary = "查询长文本TTS任务状态", description = "根据taskId查询任务状态,成功时返回缓存对象信息")
// @GetMapping(value = "/long/status")
// @IgnoreAuth
// public Result<org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache> queryStatus(
// @Parameter(description = "任务ID", required = true) @RequestParam("taskId") String taskId
// ) {
// org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache cache = appletApiTTService.queryLongTextTtsTaskStatus(taskId);
// return Result.OK(cache);
// }
@Operation(summary = "接收长文本TTS回调", description = "接收腾讯云回调并保存音频与状态")
@PostMapping(value = "/long/callback")
@IgnoreAuth
public Result<Boolean> ttsCallback(@RequestBody Map<String, Object> payload) {
try {
log.info("回调内容: {}", payload);
String taskId = (String) payload.get("TaskId");
Boolean success = payload.get("Success") instanceof Boolean ? (Boolean) payload.get("Success") : null;
String audioBase64 = (String) payload.get("Audio");
Integer sampleRate = payload.get("SampleRate") instanceof Number ? ((Number) payload.get("SampleRate")).intValue() : null;
String codec = (String) payload.get("Codec");
String message = (String) payload.get("Message");
boolean ok = appletApiTTService.handleTtsCallback(taskId, audioBase64, sampleRate, codec, success != null && success, message);
return ok ? Result.OK(true) : Result.error("回调处理失败");
} catch (Exception e) {
log.error("回调解析失败: {}", e.getMessage(), e);
return Result.error("回调解析失败: " + e.getMessage());
}
}
}

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

@ -3,6 +3,7 @@ package org.jeecg.modules.applet.service;
import com.tencentcloudapi.tts.v20190823.models.TextToVoiceResponse;
import org.jeecg.modules.applet.entity.TtsVo;
import org.jeecg.modules.demo.appletTtsTimbre.entity.AppletTtsTimbre;
import org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache;
import java.util.List;
@ -20,4 +21,29 @@ public interface AppletApiTTService {
List<AppletTtsTimbre> list();
/**
* 创建长文本TTS任务异步如已在生成中则直接返回任务信息
*/
String createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl);
/**
* 按页面创建长文本TTS任务异步仅接收页面ID与音色ID
*/
String createLongTextTtsTaskByPage(String pageId, Integer voiceType);
/**
* 根据页面ID提取页面文本内容提供给长文本方法使用
*/
String extractTextByPageId(String pageId);
/**
* 查询长文本TTS任务状态并在成功时保存到缓存并更新状态
*/
AppletTtsCache queryLongTextTtsTaskStatus(String taskId);
/**
* 接收腾讯云TTS异步回调并处理结果保存到缓存并更新状态
*/
boolean handleTtsCallback(String taskId, String audioBase64, Integer sampleRate, String codec, boolean success, String message);
}

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

@ -1,6 +1,9 @@
package org.jeecg.modules.applet.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.profile.ClientProfile;
@ -8,6 +11,10 @@ import com.tencentcloudapi.common.profile.HttpProfile;
import com.tencentcloudapi.tts.v20190823.TtsClient;
import com.tencentcloudapi.tts.v20190823.models.TextToVoiceRequest;
import com.tencentcloudapi.tts.v20190823.models.TextToVoiceResponse;
import com.tencentcloudapi.tts.v20190823.models.CreateTtsTaskRequest;
import com.tencentcloudapi.tts.v20190823.models.CreateTtsTaskResponse;
import com.tencentcloudapi.tts.v20190823.models.DescribeTtsTaskStatusRequest;
import com.tencentcloudapi.tts.v20190823.models.DescribeTtsTaskStatusResponse;
import lombok.extern.log4j.Log4j2;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.util.AppletUserUtil;
@ -15,6 +22,10 @@ import org.jeecg.common.util.oss.OssBootUtil;
import org.jeecg.modules.applet.entity.TtsVo;
import org.jeecg.modules.applet.service.AppletApiTTService;
import org.jeecg.modules.common.IdUtils;
import org.jeecg.modules.demo.appletCoursePage.entity.AppletCoursePage;
import org.jeecg.modules.demo.appletCoursePage.service.IAppletCoursePageService;
import org.jeecg.modules.demo.appletCoursePageWord.entity.AppletCoursePageWord;
import org.jeecg.modules.demo.appletCoursePageWord.service.IAppletCoursePageWordService;
import org.jeecg.modules.demo.appletTtsPlayLog.service.IAppletTtsPlayLogService;
import org.jeecg.modules.demo.appletTtsTimbre.entity.AppletTtsTimbre;
import org.jeecg.modules.demo.appletTtsTimbre.service.IAppletTtsTimbreService;
@ -28,6 +39,10 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.UUID;
import java.util.Base64;
import java.net.URL;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
@Log4j2
@Service
@ -37,6 +52,8 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
private String secretId;
@Value("${tencent.secretKey}")
private String secretKey;
@Value("${tencent.TtscallbackUrl}")
private String TtscallbackUrl;
@Autowired
private IAppletTtsTimbreService appletTtsTimbreService;
@ -44,6 +61,10 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
private IAppletTtsPlayLogService appletTtsPlayLogService;
@Autowired
private IAppletTtsCacheService appletTtsCacheService;
@Autowired
private IAppletCoursePageService appletCoursePageService;
@Autowired
private IAppletCoursePageWordService appletCoursePageWordService;
public TtsVo textToVoice(String text, Float speed, Integer voiceType, Float volume, String codec) {
@ -208,6 +229,397 @@ public class AppletApiTTServiceImpl implements AppletApiTTService {
return null;
}
}
@Override
public String createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl) {
try {
// 先检查是否存在生成中的任务相同文本与参数
LambdaQueryWrapper<AppletTtsCache> generatingWrapper = new LambdaQueryWrapper<>();
generatingWrapper.eq(AppletTtsCache::getText, text)
.eq(AppletTtsCache::getVoiceType, voiceType)
.eq(volume != null, AppletTtsCache::getVolume, volume != null ? volume.doubleValue() : null)
.eq(speed != null, AppletTtsCache::getSpeed, speed != null ? speed.doubleValue() : null)
.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::getText, text)
.eq(AppletTtsCache::getVoiceType, voiceType)
.eq(volume != null, AppletTtsCache::getVolume, volume != null ? volume.doubleValue() : null)
.eq(speed != null, AppletTtsCache::getSpeed, speed != null ? speed.doubleValue() : null)
.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 (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);
// 启用时间戳字幕
try {
req.getClass().getMethod("setEnableSubtitle", Boolean.class).invoke(req, Boolean.TRUE);
} catch (Exception ignore) {}
if (callbackUrl != null && !callbackUrl.isEmpty()) {
req.setCallbackUrl(callbackUrl);
}
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.setVolume(volume != null ? volume.doubleValue() : null);
cache.setSpeed(speed != null ? speed.doubleValue() : null);
cache.setSuccess("N");
cache.setState(0);
cache.setCreateTime(new java.util.Date());
appletTtsCacheService.save(cache);
log.info("长文本TTS任务创建成功,taskId: {}", taskId);
return taskId;
} catch (Exception e) {
log.error("创建长文本TTS任务失败: {}", e.getMessage(), e);
throw new JeecgBootException("创建长文本TTS任务失败: " + e.getMessage());
}
}
@Override
public AppletTtsCache queryLongTextTtsTaskStatus(String taskId) {
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);
DescribeTtsTaskStatusRequest req = new DescribeTtsTaskStatusRequest();
req.setTaskId(taskId);
DescribeTtsTaskStatusResponse resp = client.DescribeTtsTaskStatus(req);
Long status = resp.getData() != null ? resp.getData().getStatus() : null; // 0等待 1处理中 2成功 3失败
String audioUrl = resp.getData() != null ? resp.getData().getResultUrl() : null;
AppletTtsCache cache = appletTtsCacheService
.lambdaQuery()
.eq(AppletTtsCache::getTaskId, taskId)
.one();
if (cache == null) {
cache = new AppletTtsCache();
cache.setTaskId(taskId);
cache.setCreateTime(new java.util.Date());
cache.setState(0);
cache.setSuccess("N");
appletTtsCacheService.save(cache);
}
if (status == null || status == 0 || status == 1) {
cache.setState(0);
cache.setUpdateTime(new java.util.Date());
appletTtsCacheService.updateById(cache);
return cache;
} else if (status == 2) {
// 成功处理音频
byte[] audioData = null;
if (audioUrl != null && !audioUrl.isEmpty()) {
audioData = downloadBytes(audioUrl);
}
if (audioData != null) {
String fileName = IdUtils.generateNo("TTS_") + System.currentTimeMillis() + ".wav";
ByteArrayInputStream inputStream = new ByteArrayInputStream(audioData);
String uploadedUrl = OssBootUtil.upload(inputStream, "tts/" + fileName);
cache.setAudioId(uploadedUrl != null ? uploadedUrl : audioUrl);
Double realDuration = AudioDurationUtil.calculateDuration(audioData);
if (realDuration != null) {
cache.setDuration(realDuration);
}
} else {
cache.setAudioId(audioUrl);
}
// 解析时间戳
String timestampsJson = null;
try {
timestampsJson = JSON.toJSONString(resp.getData().getSubtitles());
cache.setTimestamps(timestampsJson);
} catch (Exception ignore) {}
cache.setSuccess("Y");
cache.setState(1);
cache.setUpdateTime(new java.util.Date());
appletTtsCacheService.updateById(cache);
return cache;
} else {
cache.setSuccess("N");
cache.setState(0);
cache.setUpdateTime(new java.util.Date());
appletTtsCacheService.updateById(cache);
return cache;
}
} catch (Exception e) {
log.error("查询长文本TTS任务状态失败: {}", e.getMessage(), e);
throw new JeecgBootException("查询长文本TTS任务状态失败: " + e.getMessage());
}
}
/**
* 按页面创建长文本TTS任务异步仅接收页面ID与音色ID
*/
@Override
public String createLongTextTtsTaskByPage(String pageId, Integer voiceType) {
String text = extractTextByPageId(pageId);
if (text == null || text.trim().isEmpty()) {
throw new JeecgBootException("页面内容为空,无法创建TTS任务");
}
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;
} catch (Exception e) {
log.error("创建页面长文本TTS任务失败: {}", e.getMessage(), e);
throw new JeecgBootException("创建页面长文本TTS任务失败: " + e.getMessage());
}
}
/**
* 根据页面ID提取页面文本内容
*/
@Override
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);
if (page == null) {
throw new JeecgBootException("未找到页面: " + pageId);
}
String content = page.getContent();
if (content == null || content.trim().isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
try {
// content 期望是数组[{ type: 'text'|'image'|'video', content?: string, language?: 'zh'|'en', ... }]
JSONArray arr = JSON.parseArray(content);
for (int i = 0; i < arr.size(); i++) {
JSONObject obj = arr.getJSONObject(i);
if (obj == null) continue;
String type = obj.getString("type");
if ("text".equalsIgnoreCase(type)) {
String text = obj.getString("content");
if (text != null && !text.trim().isEmpty()) {
sb.append(text.trim()).append("\n");
}
}
}
return sb.toString().trim();
} catch (Exception e) {
// 若不是数组尝试作为单个对象或直接文本
try {
JSONObject obj = JSON.parseObject(content);
String type = obj.getString("type");
if ("text".equalsIgnoreCase(type)) {
String text = obj.getString("content");
return text != null ? text : "";
}
} catch (Exception ignore) {}
// 作为纯文本返回
return content;
}
}
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
public boolean handleTtsCallback(String taskId, String audioBase64, Integer sampleRate, String codec, boolean success, String message) {
try {
AppletTtsCache cache = appletTtsCacheService
.lambdaQuery()
.eq(AppletTtsCache::getTaskId, taskId)
.one();
if (cache == null) {
cache = new AppletTtsCache();
cache.setTaskId(taskId);
cache.setCreateTime(new java.util.Date());
}
if (success) {
byte[] audioData = null;
if (audioBase64 != null && !audioBase64.isEmpty()) {
audioData = Base64.getDecoder().decode(audioBase64);
}
String fileSuffix = (codec != null && !codec.isEmpty()) ? codec : "wav";
String fileName = IdUtils.generateNo("TTS_") + System.currentTimeMillis() + "." + fileSuffix;
String uploadedUrl = null;
if (audioData != null) {
ByteArrayInputStream inputStream = new ByteArrayInputStream(audioData);
uploadedUrl = OssBootUtil.upload(inputStream, "tts/" + fileName);
Double realDuration = AudioDurationUtil.calculateDuration(audioData);
if (realDuration != null) {
cache.setDuration(realDuration);
}
}
cache.setAudioId(uploadedUrl);
cache.setSuccess("Y");
cache.setState(1);
} else {
cache.setSuccess("N");
cache.setState(0);
}
cache.setUpdateTime(new java.util.Date());
if (cache.getId() == null) {
appletTtsCacheService.save(cache);
} else {
appletTtsCacheService.updateById(cache);
}
log.info("TTS回调处理完成,taskId: {}, success: {}, message: {}", taskId, success, message);
return true;
} catch (Exception e) {
log.error("处理TTS回调失败: {}", e.getMessage(), e);
return false;
}
}
private byte[] downloadBytes(String url) {
try (InputStream in = new URL(url).openStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) {
baos.write(buf, 0, len);
}
return baos.toByteArray();
} catch (Exception e) {
log.warn("下载音频失败: {}", e.getMessage());
return null;
}
}
/**
* 保存TTS播放日志


+ 8
- 0
jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletTtsCache/entity/AppletTtsCache.java View File

@ -17,6 +17,7 @@ import org.jeecgframework.poi.excel.annotation.Excel;
import org.jeecg.common.aspect.annotation.Dict;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.EqualsAndHashCode;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.experimental.Accessors;
/**
@ -60,6 +61,10 @@ public class AppletTtsCache implements Serializable {
@Excel(name = "音频文件ID/URL", width = 15)
@Schema(description = "音频文件ID/URL")
private java.lang.String audioId;
/**任务ID*/
@Excel(name = "任务ID", width = 15)
@Schema(description = "长文本TTS任务ID")
private java.lang.String taskId;
/**音频时长(秒)*/
@Excel(name = "音频时长(秒)", width = 15)
@Schema(description = "音频时长(秒)")
@ -92,4 +97,7 @@ public class AppletTtsCache implements Serializable {
@Excel(name = "状态", width = 15)
@Schema(description = "状态 0生成中 1已完成")
private java.lang.Integer state;
@Schema(description = "时间戳")
private java.lang.String timestamps;
}

+ 5
- 3
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml View File

@ -1,5 +1,5 @@
server:
port: 8003
port: 8002
undertow:
# max-http-post-size: 10MB # 平替 tomcat server.tomcat.max-swallow-siz, undertow该值默认为-1
worker-threads: 16 # 4核CPU标准配置
@ -344,10 +344,12 @@ wechat:
merchantSerialNumber: 55B6EF6CCD7CDC41504138E6B0B7E3EE23209A65 # 商户API证书序列号
apiV3Key: 0fdb77429ffdf206c151af76a663041c # 商户APIV3密钥
refundNotifyUrl: # 退款通知地址(正式环境)
transferNotify: https://www.yurangongfang.com/massage-admin/massage/cash/cashoutNotify/ # 转账结果通知地址
transferNotify: https://www.multipleculture.com/englishread-admin/massage/cash/cashoutNotify/ # 转账结果通知地址
testPrice: true
tencent:
secretId: AKIDDH7j7IFzgCUbUBfaJuJVTDk4jCVbT7Xm
secretKey: 4NURCj281g7RWP4Vj8KJ5dy5zf9PSIuN
secretKey: 4NURCj281g7RWP4Vj8KJ5dy5zf9PSIuN
TtscallbackUrl: http://h5.xzaiyp.top/englishread-admin/appletApi/tts/long/callback
# TtscallbackUrl: https://www.multipleculture.com/englishread-admin/appletApi/tts/long/callback

+ 4
- 204
jeecgboot-vue3/src/views/applet/course-page/AppletCoursePageList.vue View File

@ -71,129 +71,8 @@
<div class="editor-panel">
<div class="panel-content">
<a-form :model="currentPage" layout="vertical" class="page-form">
<!-- 内容编辑区域 -->
<div class="content-editor-section">
<div class="section-header">
<span>页面内容</span>
<a-button-group size="small">
<a-button @click="addTextContent">
<Icon icon="ant-design:font-size-outlined" />
文本
</a-button>
<a-button @click="addImageContent">
<Icon icon="ant-design:picture-outlined" />
图片
</a-button>
<a-button @click="addVideoContent">
<Icon icon="ant-design:video-camera-outlined" />
视频
</a-button>
</a-button-group>
</div>
<!-- 内容组件列表 -->
<div class="content-components">
<draggable
v-model="contentComponents"
item-key="id"
handle=".drag-handle"
animation="200"
class="draggable-list"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element: component, index }">
<div class="content-component">
<!-- 文本组件 -->
<div v-if="component.type === 'text'" class="text-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:font-size-outlined" />
<span>文本内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<!-- 语言选择 -->
<div class="language-selector">
<a-radio-group v-model:value="component.language" size="small">
<a-radio value="zh">中文</a-radio>
<a-radio value="en">英文</a-radio>
</a-radio-group>
</div>
<a-textarea v-model:value="component.content" :rows="4" placeholder="请输入文本内容" />
</div>
<!-- 图片组件 -->
<div v-else-if="component.type === 'image'" class="image-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:picture-outlined" />
<span>图片内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JImageUpload
v-model:value="component.imageUrl"
:fileMax="1"
listType="picture-card"
text="上传图片"
bizPath="course"
:accept="['image/*']"
@change="handleImageChange($event, component)"
/>
<a-input v-model:value="component.alt" placeholder="图片描述(可选)" style="margin-top: 8px;" />
</div>
<!-- 视频组件 -->
<div v-else-if="component.type === 'video'" class="video-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:video-camera-outlined" />
<span>视频内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JUpload
v-model:value="component.url"
bizPath="course"
text="上传视频"
@change="handleVideoChange($event, component)"
style="margin-top: 8px;"
/>
<div style="margin-top: 12px;">
<div style="margin-bottom: 8px; font-size: 14px; color: #666;">视频封面</div>
<JImageUpload
v-model:value="component.coverUrl"
:fileMax="1"
listType="picture-card"
text="上传封面"
bizPath="course"
:accept="['image/*']"
@change="handleVideoCoverChange($event, component)"
/>
</div>
</div>
</div>
</template>
</draggable>
<!-- 空状态 -->
<div v-if="contentComponents.length === 0" class="empty-content">
<Icon icon="ant-design:plus-circle-outlined" class="empty-icon" />
<p>点击上方按钮添加内容组件</p>
</div>
</div>
</div>
<!-- 内容编辑组件 -->
<ContentEditor v-model:components="contentComponents" />
<!-- 重点单词表格组件 -->
<WordTable :course-page-id="currentPage.id" ref="wordTableRef" />
@ -253,7 +132,8 @@ import { ref, reactive, computed, unref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { Icon } from '/@/components/Icon';
import { JImageUpload, JUpload } from '/@/components/Form';
//
import ContentEditor from './components/ContentEditor.vue';
import { saveOrUpdate, getById, list, deleteOne, add, edit } from './AppletCoursePage.api';
import { initDictOptions } from '/@/utils/dict/JDictSelectUtil';
import draggable from 'vuedraggable';
@ -655,86 +535,6 @@ function refreshPreview() {
}
/**
* 添加文本内容
*/
function addTextContent() {
contentComponents.value.push({
id: Date.now() + Math.random(), // ID
type: 'text',
content: '',
language: 'zh' //
});
}
/**
* 添加图片内容
*/
function addImageContent() {
contentComponents.value.push({
id: Date.now() + Math.random(), // ID
type: 'image',
imageUrl: '',
alt: ''
});
}
/**
* 添加视频内容
*/
function addVideoContent() {
contentComponents.value.push({
id: Date.now() + Math.random(), // ID
type: 'video',
url: '',
coverUrl: ''
});
}
/**
* 移除组件
*/
function removeComponent(index: number) {
contentComponents.value.splice(index, 1);
}
/**
* 图片上传变化处理
*/
function handleImageChange(value: string, component: any) {
// JImageUploadURL
component.imageUrl = value;
}
/**
* 视频上传变化处理
*/
function handleVideoChange(value: string, component: any) {
component.url = value;
}
/**
* 视频封面上传变化处理
*/
function handleVideoCoverChange(value: string, component: any) {
component.coverUrl = value;
}
/**
* 拖拽开始事件
*/
function onDragStart(evt: any) {
console.log('拖拽开始:', evt);
}
/**
* 拖拽结束事件
*/
function onDragEnd(evt: any) {
console.log('拖拽结束:', evt);
// Vue Draggable v-model
//
createMessage.success('组件顺序已更新');
}
/**
* 获取页面类型图标


+ 463
- 0
jeecgboot-vue3/src/views/applet/course-page/components/ContentEditor.vue View File

@ -0,0 +1,463 @@
<template>
<div class="content-editor-section">
<div class="section-header">
<span>页面内容</span>
<a-button-group size="small">
<a-button @click="addTextContent">
<Icon icon="ant-design:font-size-outlined" />
文本
</a-button>
<a-button @click="addImageContent">
<Icon icon="ant-design:picture-outlined" />
图片
</a-button>
<a-button @click="addVideoContent">
<Icon icon="ant-design:video-camera-outlined" />
视频
</a-button>
</a-button-group>
</div>
<!-- 内容组件列表 -->
<div class="content-components">
<draggable
v-model="componentsRef"
item-key="id"
handle=".drag-handle"
animation="200"
class="draggable-list"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{ element: component, index }">
<div class="content-component">
<!-- 文本组件 -->
<div v-if="component.type === 'text'" class="text-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:font-size-outlined" />
<span>文本内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<!-- 语言选择 -->
<div class="language-selector">
<a-radio-group v-model:value="component.language" size="small" @change="emitUpdate">
<a-radio value="zh">中文</a-radio>
<a-radio value="en">英文</a-radio>
</a-radio-group>
</div>
<!-- 文本样式控制横向显示自动换行 -->
<div class="text-style-controls">
<div class="style-row">
<span class="style-label">字体大小</span>
<a-input-number :min="10" :max="100" :value="getFontSizeNumber(component)" @change="(val)=>setFontSizeNumber(component, Number(val))" />
<span class="unit-label">rpx</span>
</div>
<div class="style-row">
<span class="style-label">字体颜色</span>
<input type="color" :value="component.style?.color || '#333333'" @input="(e)=>setTextColor(component, (e.target as HTMLInputElement).value)" class="color-input" />
<a-input v-model:value="component.style.color" placeholder="#333333" style="width: 120px; margin-left: 8px;" @change="emitUpdate" />
</div>
<div class="style-row">
<span class="style-label">字体样式</span>
<a-select :value="component.style?.fontFamily || 'SimSun'" style="width: 160px" @change="(val)=>setFontFamily(component, String(val))">
<a-select-option value="SimSun">宋体 (SimSun)</a-select-option>
<a-select-option value="SimHei">黑体 (SimHei)</a-select-option>
<a-select-option value="Microsoft YaHei">微软雅黑 (Microsoft YaHei)</a-select-option>
<a-select-option value="KaiTi">楷体 (KaiTi)</a-select-option>
<a-select-option value="FangSong">仿宋 (FangSong)</a-select-option>
<a-select-option value="Arial">Arial</a-select-option>
</a-select>
</div>
<div class="style-row">
<span class="style-label">加粗</span>
<a-switch :checked="component.style?.fontWeight === 'bold'" @change="(checked)=>setFontWeight(component, checked ? 'bold' : 'normal')" />
</div>
<div class="style-row">
<span class="style-label">导语</span>
<a-switch :checked="!!component.isLead" @change="(checked)=>setLead(component, checked)" />
</div>
</div>
<a-textarea v-model:value="component.content" :rows="4" placeholder="请输入文本内容" @change="emitUpdate" />
</div>
<!-- 图片组件 -->
<div v-else-if="component.type === 'image'" class="image-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:picture-outlined" />
<span>图片内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JImageUpload
v-model:value="component.imageUrl"
:fileMax="1"
listType="picture-card"
text="上传图片"
bizPath="course"
:accept="['image/*']"
@change="handleImageChange($event, component)"
/>
<a-input v-model:value="component.alt" placeholder="图片描述(可选)" style="margin-top: 8px;" @change="emitUpdate" />
</div>
<!-- 视频组件 -->
<div v-else-if="component.type === 'video'" class="video-component">
<div class="component-header">
<div class="drag-handle">
<Icon icon="ant-design:drag-outlined" />
</div>
<Icon icon="ant-design:video-camera-outlined" />
<span>视频内容</span>
<a-button size="small" type="text" danger @click="removeComponent(index)">
<Icon icon="ant-design:delete-outlined" />
</a-button>
</div>
<JUpload
v-model:value="component.url"
bizPath="course"
text="上传视频"
@change="handleVideoChange($event, component)"
style="margin-top: 8px;"
/>
<div style="margin-top: 12px;">
<div style="margin-bottom: 8px; font-size: 14px; color: #666;">视频封面</div>
<JImageUpload
v-model:value="component.coverUrl"
:fileMax="1"
listType="picture-card"
text="上传封面"
bizPath="course"
:accept="['image/*']"
@change="handleVideoCoverChange($event, component)"
/>
</div>
</div>
</div>
</template>
</draggable>
<!-- 空状态 -->
<div v-if="componentsRef.length === 0" class="empty-content">
<Icon icon="ant-design:plus-circle-outlined" class="empty-icon" />
<p>点击上方按钮添加内容组件</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { JImageUpload, JUpload } from '/@/components/Form';
import draggable from 'vuedraggable';
import { useMessage } from '/@/hooks/web/useMessage';
const props = defineProps<{ components: any[] }>();
const emit = defineEmits<{ (e: 'update:components', value: any[]): void }>();
const { createMessage } = useMessage();
const componentsRef = ref<any[]>([]);
watch(
() => props.components,
(val) => {
// px -> rpx
const cloned = Array.isArray(val) ? JSON.parse(JSON.stringify(val)) : [];
componentsRef.value = cloned.map((c: any) => {
if (c?.type === 'text') {
if (!c.style) c.style = {};
if (!c.style.fontSize) c.style.fontSize = '30rpx';
if (!c.style.color) c.style.color = '#333333';
if (!c.style.fontFamily) c.style.fontFamily = 'SimSun';
if (!c.style.fontWeight) c.style.fontWeight = 'normal';
if (typeof c.isLead === 'undefined') c.isLead = false;
// px rpx rpx
if (typeof c.style.fontSize === 'string') {
if (/px$/i.test(c.style.fontSize)) {
c.style.fontSize = c.style.fontSize.replace(/px$/i, 'rpx');
} else if (/^\d+$/.test(c.style.fontSize)) {
c.style.fontSize = `${c.style.fontSize}rpx`;
}
}
}
return c;
});
},
{ immediate: true, deep: true }
);
function emitUpdate() {
emit('update:components', componentsRef.value);
}
function addTextContent() {
componentsRef.value.push({
id: Date.now() + Math.random(),
type: 'text',
content: '',
language: 'zh',
style: {
fontSize: '30rpx',
color: '#333333',
fontFamily: 'SimSun',
},
isLead: false,
});
emitUpdate();
}
function addImageContent() {
componentsRef.value.push({
id: Date.now() + Math.random(),
type: 'image',
imageUrl: '',
alt: '',
});
emitUpdate();
}
function addVideoContent() {
componentsRef.value.push({
id: Date.now() + Math.random(),
type: 'video',
url: '',
coverUrl: '',
});
emitUpdate();
}
function removeComponent(index: number) {
componentsRef.value.splice(index, 1);
emitUpdate();
}
function handleImageChange(value: string, component: any) {
component.imageUrl = value;
emitUpdate();
}
function handleVideoChange(value: string, component: any) {
component.url = value;
emitUpdate();
}
function handleVideoCoverChange(value: string, component: any) {
component.coverUrl = value;
emitUpdate();
}
function onDragStart(evt: any) {
// no-op
}
function onDragEnd(evt: any) {
createMessage.success('组件顺序已更新');
emitUpdate();
}
function ensureStyle(component: any) {
if (!component.style) component.style = {};
}
function getFontSizeNumber(component: any) {
const s = component.style?.fontSize;
if (!s) return 30;
const m = typeof s === 'string' ? s.match(/(\d+)/) : null;
return m ? Number(m[1]) : 30;
}
function setFontSizeNumber(component: any, value: number) {
ensureStyle(component);
// rpx
component.style.fontSize = `${value}rpx`;
emitUpdate();
}
function setTextColor(component: any, color: string) {
ensureStyle(component);
component.style.color = color;
emitUpdate();
}
function setFontFamily(component: any, family: string) {
ensureStyle(component);
component.style.fontFamily = family;
emitUpdate();
}
function setFontWeight(component: any, weight: string) {
ensureStyle(component);
component.style.fontWeight = weight;
emitUpdate();
}
function setLead(component: any, val: boolean) {
component.isLead = val;
emitUpdate();
}
</script>
<style scoped>
.content-editor-section {
margin-top: 24px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header span {
font-size: 14px;
font-weight: 500;
color: #262626;
}
.content-components {
display: flex;
flex-direction: column;
gap: 16px;
}
.content-component {
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 6px;
background: #fafafa;
transition: all 0.3s;
overflow: hidden;
}
.component-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
background-color: #fafafa;
border-radius: 6px 6px 0 0;
border-bottom: 1px solid #f0f0f0;
}
.component-header span {
font-size: 14px;
font-weight: 500;
color: #262626;
flex: 1;
}
.language-selector {
margin-bottom: 12px;
padding: 8px 12px;
background: #f0f0f0;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
.language-selector .ant-radio-group {
display: flex;
gap: 16px;
}
.text-style-controls {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.style-row {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 12px;
}
.style-label {
min-width: 72px;
color: #595959;
}
.unit-label {
color: #8c8c8c;
}
.color-input {
width: 36px;
height: 28px;
padding: 0;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.draggable-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.drag-handle {
cursor: move;
padding: 4px;
color: #8c8c8c;
transition: color 0.3s;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
}
.drag-handle:hover {
color: #1890ff;
background-color: #f0f8ff;
}
.sortable-ghost {
opacity: 0.5;
background-color: #f5f5f5;
border: 2px dashed #d9d9d9;
}
.sortable-chosen {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
border: 2px solid #1890ff;
background-color: #f0f8ff;
}
.sortable-drag {
opacity: 0.8;
transform: rotate(5deg);
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: #8c8c8c;
border: 2px dashed #d9d9d9;
border-radius: 6px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
color: #d9d9d9;
}
</style>

+ 53
- 9
jeecgboot-vue3/src/views/applet/course-page/components/MobilePreview.vue View File

@ -14,11 +14,11 @@
<p>暂无内容请在左侧添加内容组件</p>
</div>
<div v-else class="content-preview">
<div v-for="(component, index) in contentComponents" :key="index" class="preview-component">
<div v-for="(component, index) in contentComponents" :key="index" class="preview-component" :class="{ 'lead-paragraph': component.type==='text' && component.isLead }">
<!-- 文本预览 -->
<div v-if="component.type === 'text'" class="text-preview" :class="{ 'text-english': component.language === 'en' }">
<div class="text-language-tag">{{ component.language === 'en' ? 'EN' : '中' }}</div>
<pre>{{ component.content || '文本内容...' }}</pre>
<div v-if="component.type === 'text'" class="text-preview">
<!-- <div class="text-language-tag">{{ component.language === 'en' ? 'EN' : '中' }}</div> -->
<pre :style="getTextStyle(component)">{{ component.content || '文本内容...' }}</pre>
</div>
<!-- 图片预览 -->
<div v-else-if="component.type === 'image'" class="image-preview">
@ -50,6 +50,7 @@
<script lang="ts" setup>
import { Icon } from '/@/components/Icon/index';
import { computed } from 'vue';
//
interface Props {
@ -79,6 +80,38 @@ const emit = defineEmits<Emits>();
const handleRefresh = () => {
emit('refresh');
};
// rpx -> px 375 1rpx /750 px
function rpxToPx(value?: string): string | undefined {
if (!value) return undefined;
const m = value.match(/^(\d+)(rpx|px)$/i);
if (!m) return value;
const num = Number(m[1]);
const unit = m[2].toLowerCase();
if (unit === 'px') return `${num}px`;
const screenWidth = 375; //
const px = (screenWidth / 750) * num; // 1rpx = screenWidth/750 px
return `${px}px`;
}
function getTextStyle(component: any) {
const style = component.style || {};
const fontSize = rpxToPx(style.fontSize) || rpxToPx('30rpx') || '15px';
const color = style.color || '#262626';
const fontFamily = style.fontFamily || (component.language === 'en' ? "Arial, Helvetica, sans-serif" : undefined);
const fontWeight = style.fontWeight || 'normal';
const result: Record<string, string> = {
fontSize,
color,
fontWeight,
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: '0'
};
if (fontFamily) result.fontFamily = fontFamily;
return result;
}
</script>
<style lang="less" scoped>
@ -168,11 +201,22 @@ const handleRefresh = () => {
gap: 12px;
}
.mobile-screen .preview-component {
background: #f9f9f9;
border-radius: 8px;
padding: 12px;
}
.mobile-screen .preview-component {
//background: #f9f9f9;
border-radius: 8px;
padding: 12px;
}
/* 导语特殊背景样式 */
.mobile-screen .lead-paragraph {
background: #fffbe6; /* 柔和的提示背景 */
border: 1px solid #ffe58f;
border-radius: 8px;
}
.mobile-screen .lead-paragraph .text-preview pre {
/* 可适度加深文字颜色以增强可读性 */
color: #262626;
}
.mobile-screen .text-preview {
position: relative;


Loading…
Cancel
Save