前端优化

This commit is contained in:
2025-11-22 18:30:02 +08:00
parent 307c90f93e
commit fee84ce822
8 changed files with 198 additions and 167 deletions

View File

@@ -3,7 +3,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, nextTick,onUnmounted } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
const props = defineProps({
@@ -17,93 +17,94 @@ const props = defineProps({
}
})
// 当前渲染的内容(避免重复渲染)
const currentContent = ref('')
// 渲染的 HTML 内容
// 内部维护的纯文本内容(用于计算增量和渲染
const internalContent = ref('')
// 最终渲染的 HTML
const renderedContent = ref('')
/**
* 更新渲染内容
* 只有当内容真正改变时才更新,避免重复渲染
*/
function updateRenderedContent() {
const content = currentContent.value
let debounceTimer = null
// 避免重复渲染相同内容
if (content === renderedContent.value.replace(/<[^>]*>/g, '')) {
return
}
if (!content) {
renderedContent.value = ''
return
}
// 渲染 markdown 为 HTML
renderedContent.value = renderMarkdown(content)
}
/**
* 处理内容更新
* 流式渲染:需要拼接增量内容
*/
function handleContentUpdate(newContent) {
if (!newContent) {
currentContent.value = ''
updateRenderedContent()
return
}
// 流式模式下拼接增量内容
if (props.isStreaming) {
currentContent.value += newContent
// 仅在流式时计算并追加增量
function appendStreamingDelta(newFullContent) {
const prev = internalContent.value
if (newFullContent.startsWith(prev)) {
// 正常情况:新内容包含旧内容 → 只追加差值
const delta = newFullContent.slice(prev.length)
internalContent.value += delta
} else {
// 非流式模式下直接替换
currentContent.value = newContent
// 异常情况(如后端重发了不连续的内容),直接覆盖防止乱序
console.warn('[PromptDisplay] Streaming content out of order, forcing replace')
internalContent.value = newFullContent
}
updateRenderedContent()
}
// 监听 content 变化,使用防抖处理避免频繁更新
let updateTimeout = null
watch(() => props.content, (newContent) => {
// 清除之前的定时器
if (updateTimeout) {
clearTimeout(updateTimeout)
// 更新 Markdown 渲染
async function updateRendered() {
if (!internalContent.value) {
renderedContent.value = ''
return
}
// renderMarkdown 可能包含异步操作(如 highlight.js用 nextTick 确保 DOM 就绪
renderedContent.value = await renderMarkdown(internalContent.value)
}
// 主更新逻辑
function updateContent(newContent = '') {
if (props.isStreaming) {
appendStreamingDelta(newContent)
} else {
internalContent.value = newContent
}
// 延迟更新,避免流式传输时频繁更新导致的性能问题
updateTimeout = setTimeout(() => {
handleContentUpdate(newContent)
}, 50) // 50ms 防抖
})
// 流式直接渲染,非流式防抖(避免频繁完整替换导致光标跳动或闪烁)
if (props.isStreaming) {
updateRendered()
} else {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
updateRendered()
}, 60) // 60ms 足够平滑且不卡顿
}
}
// 监听 isStreaming 变化
// 监听 content 变化(外部每次推送的都是当前完整内容)
watch(() => props.content, (newVal) => {
updateContent(newVal || '')
}, { immediate: true })
// 当流式开始时清空,防止旧内容残留
watch(() => props.isStreaming, (newVal, oldVal) => {
// 流式传输开始时,清空之前的内容
if (newVal && !oldVal) {
currentContent.value = ''
internalContent.value = ''
renderedContent.value = ''
}
// 流式传输结束时,确保显示完整内容
if (!newVal && oldVal && props.content) {
currentContent.value = props.content
updateRenderedContent()
}
})
// 立即渲染初始内容
handleContentUpdate(props.content)
// 可选:组件卸载时清理定时器
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
</script>
<style scoped>
/* 修复 pre 标签撑开容器的问题 */
.prompt-display {
line-height: 1.6;
}
/* 代码块优化 */
.prompt-display :deep(pre) {
max-width: 100%;
overflow-x: auto;
word-break: break-word;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
background: #282c34;
color: #abb2bf;
padding: 1em;
border-radius: 8px;
margin: 1em 0;
}
</style>
.prompt-display :deep(code) {
font-family: 'Fira Code', Menlo, Monaco, Consolas, 'Courier New', monospace;
}
</style>

View File

@@ -32,7 +32,6 @@ const editForm = reactive({
content: '',
category: '',
status: 1,
_originalRecord: null, // 保存原始记录,用于更新时获取必需字段
})
const editFormRef = ref(null)
@@ -173,12 +172,7 @@ function handleReset() {
// 新增
function handleAdd() {
editForm.id = null
editForm.name = ''
editForm.content = ''
editForm.category = ''
editForm.status = 1
editForm._originalRecord = null
resetEditForm()
editModalVisible.value = true
}
@@ -188,62 +182,72 @@ function handleEdit(record) {
editForm.name = record.name || ''
editForm.content = record.content || ''
editForm.category = record.category || ''
editForm.status = record.status ?? 1
// 保存原始记录的完整信息,用于更新时传递必需字段
editForm._originalRecord = record
editForm.status = record.status !== null && record.status !== undefined ? record.status : 1
editModalVisible.value = true
}
// 表单重置
function resetEditForm() {
editForm.id = null
editForm.name = ''
editForm.content = ''
editForm.category = ''
editForm.status = 1
}
// 通用API调用
async function apiCall(apiFunc, param, successMessage, isDelete = false) {
loading.value = true
try {
const response = await apiFunc(param)
if (response && (response.code === 0 || response.code === 200)) {
message.success(successMessage)
if (!isDelete) {
editModalVisible.value = false
}
loadData()
return true
} else {
throw new Error(response?.msg || response?.message || '操作失败')
}
} catch (error) {
console.error('API调用失败:', error)
message.error(error?.message || '操作失败,请稍后重试')
return false
} finally {
loading.value = false
}
}
// 保存(新增/编辑)
async function handleSave() {
try {
await editFormRef.value.validate()
} catch (error) {
console.error('保存提示词失败:', error)
console.error('表单验证失败:', error)
return
}
loading.value = true
try {
const payload = {
name: editForm.name.trim(),
content: editForm.content.trim(),
category: editForm.category.trim() || null,
status: editForm.status,
}
const payload = {
name: editForm.name.trim(),
content: editForm.content.trim(),
category: editForm.category.trim() || null,
status: editForm.status,
}
if (editForm.id) {
// 更新:只需要传递要修改的字段,后端会自动填充其他字段
payload.id = editForm.id
// 注意sort、useCount、isPublic 等字段后端会自动从数据库获取,无需前端传递
const response = await UserPromptApi.updateUserPrompt(payload)
if (response && (response.code === 0 || response.code === 200)) {
message.success('更新成功')
editModalVisible.value = false
loadData()
} else {
throw new Error(response?.msg || response?.message || '更新失败')
}
} else {
// 新增:需要包含所有必需字段
payload.sort = 0
payload.useCount = 0
payload.isPublic = false
const response = await UserPromptApi.createUserPrompt(payload)
if (response && (response.code === 0 || response.code === 200)) {
message.success('创建成功')
editModalVisible.value = false
loadData()
} else {
throw new Error(response?.msg || response?.message || '创建失败')
}
}
} catch (error) {
console.error('保存提示词失败:', error)
message.error(error?.message || '保存失败,请稍后重试')
} finally {
loading.value = false
if (editForm.id) {
payload.id = editForm.id
await apiCall(
(data) => UserPromptApi.updateUserPrompt(data),
payload,
'更新成功'
)
} else {
await apiCall(
(data) => UserPromptApi.createUserPrompt(data),
payload,
'创建成功'
)
}
}
@@ -253,21 +257,12 @@ function handleDelete(record) {
title: '确认删除',
content: `确定要删除提示词"${record.name}"吗?`,
onOk: async () => {
loading.value = true
try {
const response = await UserPromptApi.deleteUserPrompt(record.id)
if (response && (response.code === 0 || response.code === 200)) {
message.success('删除成功')
loadData()
} else {
throw new Error(response?.msg || response?.message || '删除失败')
}
} catch (error) {
console.error('删除提示词失败:', error)
message.error(error?.message || '删除失败,请稍后重试')
} finally {
loading.value = false
}
await apiCall(
(id) => UserPromptApi.deleteUserPrompt(id),
record.id,
'删除成功',
true
)
},
})
}

View File

@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.tik.userprompt.controller.app;
package cn.iocoder.yudao.module.tik.userprompt.controller;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
@@ -23,7 +23,7 @@ import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.USER_PROMPT_N
@Tag(name = "用户 App - 用户提示词")
@RestController
@RequestMapping("/ai/user-prompt")
@RequestMapping("/api/ai/user-prompt")
@Validated
public class AppUserPromptController {

View File

@@ -29,17 +29,16 @@ public class UserPromptServiceImpl implements UserPromptService {
@Override
public Long createUserPrompt(UserPromptSaveReqVO createReqVO) {
// 插入
UserPromptDO userPrompt = BeanUtils.toBean(createReqVO, UserPromptDO.class);
if (userPrompt.getSort() == null) userPrompt.setSort(0);
if (userPrompt.getUseCount() == null) userPrompt.setUseCount(0);
if (userPrompt.getIsPublic() == null) userPrompt.setIsPublic(false);
userPromptMapper.insert(userPrompt);
// 返回
return userPrompt.getId();
}
@Override
public void updateUserPrompt(UserPromptSaveReqVO updateReqVO) {
// 1. 手动验证前端表单字段(与前端表单对应)
if (updateReqVO.getName() == null || updateReqVO.getName().trim().isEmpty()) {
throw new IllegalArgumentException("提示词名称不能为空");
}
@@ -49,42 +48,32 @@ public class UserPromptServiceImpl implements UserPromptService {
if (updateReqVO.getStatus() == null) {
throw new IllegalArgumentException("状态不能为空");
}
// 2. 校验存在并获取现有记录
UserPromptDO existing = validateUserPromptExists(updateReqVO.getId());
// 3. 手动设置要更新的字段(只更新前端表单中的字段)
UserPromptDO updateObj = new UserPromptDO();
updateObj.setId(updateReqVO.getId());
updateObj.setName(updateReqVO.getName().trim());
updateObj.setContent(updateReqVO.getContent().trim());
updateObj.setCategory(updateReqVO.getCategory() != null ? updateReqVO.getCategory().trim() : null);
updateObj.setStatus(updateReqVO.getStatus());
// 4. 自动填充前端表单中没有的字段(从数据库获取)
updateObj.setSort(existing.getSort());
updateObj.setUseCount(existing.getUseCount());
updateObj.setIsPublic(existing.getIsPublic());
updateObj.setUserId(existing.getUserId()); // 保持用户ID不变
// 5. 执行更新
updateObj.setUserId(existing.getUserId());
userPromptMapper.updateById(updateObj);
}
@Override
public void deleteUserPrompt(Long id) {
// 校验存在
validateUserPromptExists(id);
// 删除
userPromptMapper.deleteById(id);
}
@Override
public void deleteUserPromptListByIds(List<Long> ids) {
// 删除
public void deleteUserPromptListByIds(List<Long> ids) {
userPromptMapper.deleteByIds(ids);
}
}
private UserPromptDO validateUserPromptExists(Long id) {
UserPromptDO userPrompt = userPromptMapper.selectById(id);
@@ -103,5 +92,4 @@ public class UserPromptServiceImpl implements UserPromptService {
public PageResult<UserPromptDO> getUserPromptPage(UserPromptPageReqVO pageReqVO) {
return userPromptMapper.selectPage(pageReqVO);
}
}

View File

@@ -27,16 +27,13 @@ public class UserPromptSaveReqVO {
@Schema(description = "分类/标签")
private String category;
@Schema(description = "是否公开0-私有1-公开)", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "是否公开0-私有1-公开)不能为空")
@Schema(description = "是否公开0-私有1-公开)")
private Boolean isPublic;
@Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "排序不能为空")
@Schema(description = "排序")
private Integer sort;
@Schema(description = "使用次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "22185")
@NotNull(message = "使用次数不能为空")
@Schema(description = "使用次数")
private Integer useCount;
@Schema(description = "状态0-禁用1-启用)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")

View File

@@ -16,7 +16,9 @@ import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikUserVoiceDO;
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikUserVoiceMapper;
import cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStatusEnum;
import cn.iocoder.yudao.module.tik.voice.enums.DigitalHumanTaskStepEnum;
import cn.iocoder.yudao.module.tik.voice.vo.*;
@@ -48,6 +50,7 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
private final TikDigitalHumanTaskMapper taskMapper;
private final TikUserFileMapper userFileMapper;
private final TikUserVoiceMapper userVoiceMapper;
private final FileMapper fileMapper;
private final FileApi fileApi;
private final TikUserVoiceService userVoiceService;
@@ -224,9 +227,21 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
throw new IllegalArgumentException("文案不能为空");
}
// 验证音色ID必填
if (StrUtil.isBlank(reqVO.getVoiceId())) {
throw new IllegalArgumentException("音色ID不能为空");
// 验证音色参数二选一voiceId用于系统音色voiceConfigId用于用户音色
boolean hasVoiceId = StrUtil.isNotBlank(reqVO.getVoiceId());
boolean hasVoiceConfigId = reqVO.getVoiceConfigId() != null;
if (!hasVoiceId && !hasVoiceConfigId) {
throw new IllegalArgumentException("必须提供音色IDvoiceId或voiceConfigId");
}
if (hasVoiceId && hasVoiceConfigId) {
throw new IllegalArgumentException("voiceId和voiceConfigId不能同时提供");
}
// 如果是用户音色验证voiceConfigId对应的用户音色是否存在
if (hasVoiceConfigId) {
validateUserVoice(reqVO.getVoiceConfigId(), userId);
}
// 验证视频文件(必填)
@@ -253,17 +268,44 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
}
}
/**
* 验证用户音色
*/
private void validateUserVoice(Long voiceConfigId, Long userId) {
TikUserVoiceDO userVoice = userVoiceMapper.selectById(voiceConfigId);
if (userVoice == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_NOT_EXISTS, "用户音色不存在");
}
if (!userVoice.getUserId().equals(userId)) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.GENERAL_FORBIDDEN, "无权访问该音色");
}
if (StrUtil.isBlank(userVoice.getVoiceId())) {
throw new IllegalArgumentException("该音色配置无效缺少voiceId");
}
}
/**
* 创建任务记录
*/
private TikDigitalHumanTaskDO createTaskRecord(AppTikDigitalHumanCreateReqVO reqVO, Long userId) {
// 如果是用户音色需要从voiceConfigId获取voiceId
String voiceId = reqVO.getVoiceId();
if (voiceId == null && reqVO.getVoiceConfigId() != null) {
TikUserVoiceDO userVoice = userVoiceMapper.selectById(reqVO.getVoiceConfigId());
if (userVoice != null) {
voiceId = userVoice.getVoiceId();
}
}
return TikDigitalHumanTaskDO.builder()
.userId(userId)
.taskName(reqVO.getTaskName())
.aiProvider(StrUtil.blankToDefault(reqVO.getAiProvider(), "302ai"))
.videoFileId(reqVO.getVideoFileId())
.videoUrl(reqVO.getVideoUrl())
.voiceId(reqVO.getVoiceId())
.voiceId(voiceId)
.inputText(reqVO.getInputText())
.speechRate(reqVO.getSpeechRate() != null ? reqVO.getSpeechRate() : 1.0f)
.volume(reqVO.getVolume() != null ? reqVO.getVolume() : 0f)

View File

@@ -33,9 +33,12 @@ public class AppTikDigitalHumanCreateReqVO {
@Size(max = 1024, message = "视频URL不能超过1024个字符")
private String videoUrl;
@Schema(description = "音色IDCosyVoice voiceId", example = "cosyvoice-v3-flash-sys-xxx")
@Schema(description = "音色IDCosyVoice voiceId,系统音色使用", example = "cosyvoice-v3-flash-sys-xxx")
private String voiceId;
@Schema(description = "用户音色配置IDtik_user_voice.id用户音色使用", example = "123")
private Long voiceConfigId;
@Schema(description = "输入文本(用于语音合成,文案必填)", example = "您好,欢迎体验数字人")
@NotBlank(message = "文案不能为空")
@Size(max = 4000, message = "文本不能超过4000个字符")

View File

@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.tik.voice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.eclipse.angus.mail.iap.ByteArray;
/**
* Latentsync 任务结果响应 VO
@@ -21,6 +22,10 @@ public class AppTikLatentsyncResultRespVO {
@Schema(description = "视频信息")
private VideoInfo video;
@Schema(description = "视频流信息")
private ByteArray value;
@Schema(description = "视频信息")
@Data
public static class VideoInfo {