feat: 完成角色卡,智能体配置,加载,历史记录,清空等功能

This commit is contained in:
2025-11-08 21:02:31 +08:00
parent 6eb0c9c8dc
commit d27d6c3ac7
18 changed files with 441 additions and 59 deletions

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ gen/
dist/
avatar/
# 忽略特定格式的文件
uploads/
upload/
audio/
vosk-model*
*.zip

View File

@@ -0,0 +1,3 @@
-- 为 sys_role 表添加 backgroundImage 字段
-- 用于存放角色卡的背景照片
ALTER TABLE sys_role ADD COLUMN backgroundImage VARCHAR(255) DEFAULT NULL COMMENT '角色背景照片' AFTER avatar;

View File

@@ -1,10 +1,11 @@
package com.xiaozhi.controller;
import com.xiaozhi.common.web.AjaxResult;
import com.xiaozhi.plus.config.properties.LocalFileProperties;
import com.xiaozhi.utils.FileUploadUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -22,8 +23,8 @@ import java.util.UUID;
public class FileUploadController {
private static final Logger logger = LoggerFactory.getLogger(FileUploadController.class);
@Value("${xiaozhi.upload-path:uploads}")
private String uploadPath;
@Autowired
private LocalFileProperties localFileProperties;
/**
* 通用文件上传方法
@@ -49,7 +50,7 @@ public class FileUploadController {
// 使用FileUploadUtils进行智能上传根据配置自动选择上传到本地或腾讯云COS
try {
String fileUrl = FileUploadUtils.smartUpload(uploadPath, relativePath, fileName, file);
String fileUrl = FileUploadUtils.smartUpload(localFileProperties.getUploadPath(), relativePath, fileName, file);
logger.info("文件上传成功: {}", fileUrl);
// 判断是否是COS URL
@@ -62,8 +63,8 @@ public class FileUploadController {
// 如果是本地URL需要调整格式
if (!isCosUrl) {
// 将本地路径转换为访问URL格式
String accessUrl = "uploads/" + relativePath + "/" + fileName;
// 将本地路径转换为访问URL格式,使用正确的访问路径
String accessUrl = "/file/" + relativePath + "/" + fileName;
result.put("url", accessUrl);
}

View File

@@ -67,4 +67,18 @@ public class TemplateController {
}
}
/**
* 删除模板
*/
@PostMapping("/delete")
@ResponseBody
public AjaxResult delete(@RequestParam("templateId") Integer templateId) {
try {
int rows = templateService.delete(templateId);
return rows > 0 ? AjaxResult.success() : AjaxResult.error("删除模板失败");
} catch (Exception e) {
return AjaxResult.error(e.getMessage());
}
}
}

View File

@@ -3,12 +3,13 @@ package com.xiaozhi.dao;
import java.util.List;
import com.xiaozhi.entity.SysMessage;
import org.apache.ibatis.annotations.Param;
/**
* 聊天记录 数据层
*
*
* @author Joey
*
*
*/
public interface MessageMapper {
@@ -17,4 +18,11 @@ public interface MessageMapper {
int delete(SysMessage message);
List<SysMessage> query(SysMessage message);
/**
* 根据sessionId查询历史消息
* @param sessionId 会话ID
* @return 历史消息列表
*/
List<SysMessage> queryBySessionId(@Param("sessionId") String sessionId);
}

View File

@@ -3,6 +3,7 @@ package com.xiaozhi.dialogue.llm.memory;
import com.xiaozhi.entity.SysMessage;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 聊天记忆接口,全局对象,不针对单个会话,而是负责全局记忆的存储策略及针对不同类型数据库的适配。。
@@ -17,8 +18,9 @@ public interface ChatMemory {
/**
* 添加消息
* TODO 参数太多,后续考虑如何简化一些
* @return CompletableFuture用于等待消息插入完成
*/
void addMessage(String deviceId, String sessionId, String sender, String content, Integer roleId, String messageType, Long timeMillis);
CompletableFuture<Void> addMessage(String deviceId, String sessionId, String sender, String content, Integer roleId, String messageType, Long timeMillis);
/**
* 获取历史对话消息列表

View File

@@ -16,6 +16,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 基于数据库的聊天记忆实现
@@ -34,7 +35,10 @@ public class DatabaseChatMemory implements ChatMemory {
}
@Override
public void addMessage(String deviceId, String sessionId, String sender, String content, Integer roleId, String messageType, Long timeMillis) {
public CompletableFuture<Void> addMessage(String deviceId, String sessionId, String sender, String content, Integer roleId, String messageType, Long timeMillis) {
// 返回CompletableFuture用于等待消息插入完成确保插入顺序
CompletableFuture<Void> future = new CompletableFuture<>();
// 异步虚拟线程处理持久化。
Thread.startVirtualThread(() -> {
try {
@@ -54,11 +58,18 @@ public class DatabaseChatMemory implements ChatMemory {
messageService.add(message);
logger.debug("消息保存成功: deviceId={}, sessionId={}, sender={}, timeMillis={}",
deviceId, sessionId, sender, actualTimeMillis);
// 插入成功完成Future
future.complete(null);
} catch (Exception e) {
logger.error("保存消息时出错: deviceId={}, sessionId={}, sender={}, error={}",
deviceId, sessionId, sender, e.getMessage(), e);
// 插入失败完成Future并传递异常
future.completeExceptionally(e);
}
});
return future;
}
@Override

View File

@@ -8,6 +8,7 @@ import org.springframework.ai.chat.messages.*;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.concurrent.CompletableFuture;
/**
* 限定消息条数消息窗口的Conversation实现。根据不同的策略可实现聊天会话的持久化、加载、清除等功能。
@@ -110,15 +111,20 @@ public class MessageWindowConversation extends Conversation {
String messageType = hasToolCalls ? SysMessage.MESSAGE_TYPE_FUNCTION_CALL : SysMessage.MESSAGE_TYPE_NORMAL;
String deviceId = device().getDeviceId();
int roleId = role().getRoleId();
// 先插入user消息获取返回的CompletableFuture
// 如果本轮对话是function_call或mcp调用(最后一条信息的类型),把用户的消息类型也修正为同样类型
chatMemory.addMessage(deviceId, sessionId(), userMessage.getMessageType().getValue(), userMessage.getText(),
roleId, messageType, userTimeMillis);
String response = assistantMessage.getText();
if (StringUtils.hasText(response)) {
chatMemory.addMessage(deviceId, sessionId(), assistantMessage.getMessageType().getValue(), response,
roleId, messageType, assistantTimeMillis);
}
CompletableFuture<Void> userFuture = chatMemory.addMessage(deviceId, sessionId(), userMessage.getMessageType().getValue(),
userMessage.getText(), roleId, messageType, userTimeMillis);
// 等待user消息插入完成后再插入assistant消息确保数据库中的顺序
userFuture.thenRun(() -> {
String response = assistantMessage.getText();
if (StringUtils.hasText(response)) {
chatMemory.addMessage(deviceId, sessionId(), assistantMessage.getMessageType().getValue(),
response, roleId, messageType, assistantTimeMillis);
}
});
}
@Override

View File

@@ -26,6 +26,11 @@ public class SysRole extends Base<SysRole> {
*/
private String avatar;
/**
* 角色背景照片
*/
private String backgroundImage;
/**
* 角色名称
*/

View File

@@ -63,4 +63,26 @@
</if>
</update>
<!-- 根据sessionId查询历史消息 -->
<select id="queryBySessionId" resultType="com.xiaozhi.entity.SysMessage">
SELECT
messageId,
deviceId,
sessionId,
sender,
roleId,
message,
messageType,
audioPath,
state,
createTime
FROM
sys_message
WHERE
sessionId = #{sessionId}
AND state = '1'
ORDER BY
createTime ASC
</select>
</mapper>

View File

@@ -3,7 +3,7 @@
<mapper namespace="com.xiaozhi.dao.RoleMapper">
<sql id="roleSql">
sys_role.roleId, sys_role.avatar, sys_role.roleName, sys_role.roleDesc, sys_role.voiceName,
sys_role.roleId, sys_role.avatar, sys_role.backgroundImage, sys_role.roleName, sys_role.roleDesc, sys_role.voiceName,
sys_role.modelId, sys_role.templateId, sys_role.sttId, sys_role.ttsId,
sys_role.vadSpeechTh, sys_role.vadSilenceTh, sys_role.vadEnergyTh, sys_role.vadSilenceMs,
sys_role.userId, sys_role.state, sys_role.isDefault, sys_role.createTime
@@ -46,6 +46,8 @@
sys_role
<set>
avatar = #{avatar},
<if test="backgroundImage != null and backgroundImage != ''">backgroundImage = #{backgroundImage},</if>
<if test="roleName != null and roleName != ''">roleName = #{roleName},</if>
<if test="roleDesc != null and roleDesc != ''">roleDesc = #{roleDesc},</if>
<if test="voiceName != null and voiceName != ''">voiceName = #{voiceName},</if>
<if test="isDefault != null and isDefault != ''">isDefault = #{isDefault},</if>
@@ -80,8 +82,9 @@
</update>
<insert id="add" useGeneratedKeys="true" keyProperty="roleName" parameterType="com.xiaozhi.entity.SysRole">
INSERT INTO sys_role ( avatar, roleName, roleDesc, voiceName, modelId, templateId, ttsId, sttId, userId, isDefault ) VALUES (
INSERT INTO sys_role ( avatar, backgroundImage, roleName, roleDesc, voiceName, modelId, templateId, ttsId, sttId, userId, isDefault ) VALUES (
#{avatar},
#{backgroundImage},
#{roleName},
#{roleDesc},
#{voiceName},

View File

@@ -72,5 +72,8 @@
UPDATE sys_template SET isDefault = '0' WHERE isDefault = 1 AND userId = #{userId}
</update>
<delete id="delete" parameterType="Integer">
DELETE FROM sys_template WHERE templateId = #{templateId}
</delete>
</mapper>

View File

@@ -58,9 +58,39 @@ public class AppMessageController extends BaseController {
}
}
/**
* 根据sessionId查询历史消息全部
*
* @param sessionId 会话ID
* @return 历史消息列表
*/
@GetMapping("/history")
@ResponseBody
public AjaxResult getHistoryBySessionId(String sessionId) {
try {
if (sessionId == null || sessionId.trim().isEmpty()) {
return AjaxResult.error("sessionId不能为空");
}
logger.info("查询历史消息sessionId: {}", sessionId);
// 使用新的queryBySessionId方法直接根据sessionId查询
List<SysMessage> messageList = sysMessageService.queryBySessionId(sessionId);
logger.info("查询到历史消息数量: {}", messageList.size());
AjaxResult result = AjaxResult.success();
result.put("data", messageList);
return result;
} catch (Exception e) {
logger.error("查询历史消息失败, sessionId: {}, error: {}", sessionId, e.getMessage(), e);
return AjaxResult.error("查询历史消息失败: " + e.getMessage());
}
}
/**
* 删除聊天记录
*
*
* @param message
* @return
*/
@@ -81,5 +111,5 @@ public class AppMessageController extends BaseController {
return AjaxResult.error();
}
}
}

View File

@@ -21,7 +21,7 @@ import java.util.List;
*/
@RestController
@RequestMapping("/app/template")
public class AppTemplateController {
public class AppTemplateController {
@Resource
private SysTemplateService templateService;

View File

@@ -31,10 +31,18 @@ public interface SysMessageService {
/**
* 删除记忆
*
*
* @param message
* @return
*/
int delete(SysMessage message);
/**
* 根据sessionId查询历史消息
*
* @param sessionId 会话ID
* @return 历史消息列表(按时间升序)
*/
List<SysMessage> queryBySessionId(String sessionId);
}

View File

@@ -52,7 +52,7 @@ public class SysMessageServiceImpl extends BaseServiceImpl implements SysMessage
/**
* 删除记忆
*
*
* @param message
* @return
*/
@@ -62,4 +62,15 @@ public class SysMessageServiceImpl extends BaseServiceImpl implements SysMessage
return messageMapper.delete(message);
}
/**
* 根据sessionId查询历史消息
*
* @param sessionId 会话ID
* @return 历史消息列表(按时间升序)
*/
@Override
public List<SysMessage> queryBySessionId(String sessionId) {
return messageMapper.queryBySessionId(sessionId);
}
}

View File

@@ -32,6 +32,7 @@ export default {
query: "/api/template/query",
add: "/api/template/add",
update: "/api/template/update",
delete: "/api/template/delete",
},
message: {
query: "/api/message/query",

View File

@@ -109,7 +109,7 @@
<a-icon type="user" />
<p>点击上传</p>
</div>
<!-- 悬浮提示层整个区域都会显示 -->
<div class="avatar-hover-mask">
<a-icon :type="avatarLoading ? 'loading' : 'camera'" />
@@ -117,24 +117,69 @@
</div>
</div>
</a-upload>
<!-- 如果有头像显示删除按钮 -->
<a-button
v-if="avatarUrl"
type="danger"
<a-button
v-if="avatarUrl"
type="danger"
size="small"
@click.stop="removeAvatar"
class="avatar-remove-btn"
>
<a-icon type="delete" /> 移除头像
</a-button>
<div class="avatar-tip">
支持JPGPNGGIF格式不超过2MB
</div>
</div>
</a-form-item>
</a-col>
<a-col :xl="6" :lg="12" :xs="24">
<a-form-item label="角色背景照片">
<div class="background-uploader-wrapper">
<!-- 整个区域都可点击的上传组件 -->
<a-upload
name="file"
:show-upload-list="false"
:before-upload="beforeBackgroundUpload"
accept=".jpg,.jpeg,.png,.gif"
class="background-uploader"
>
<div class="background-content">
<!-- 有背景照片时显示背景照片 -->
<img v-if="backgroundImageUrl" :src="getAvatarUrl(backgroundImageUrl)" alt="角色背景" class="background-image" />
<!-- 无背景照片时显示上传图标 -->
<div v-else class="background-placeholder">
<a-icon type="picture" />
<p>点击上传背景</p>
</div>
<!-- 悬浮提示层整个区域都会显示 -->
<div class="background-hover-mask">
<a-icon :type="backgroundLoading ? 'loading' : 'picture'" />
<p>{{ backgroundImageUrl ? '更换背景' : '上传背景' }}</p>
</div>
</div>
</a-upload>
<!-- 如果有背景照片显示删除按钮 -->
<a-button
v-if="backgroundImageUrl"
type="danger"
size="small"
@click.stop="removeBackgroundImage"
class="background-remove-btn"
>
<a-icon type="delete" /> 移除背景
</a-button>
<div class="background-tip">
支持JPGPNGGIF格式不超过5MB
</div>
</div>
</a-form-item>
</a-col>
<a-col :xl="6" :lg="12" :xs="24">
<a-form-item label="角色名称">
<a-input v-decorator="[
@@ -436,18 +481,19 @@
style="margin-bottom: 16px" />
<template v-else>
<!-- 仅允许选择模板(禁用自定义) -->
<!-- 模板选择区域 -->
<div style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center">
<a-space>
<a-tag color="blue">仅支持从模板选择</a-tag>
<a-tag color="blue">选择模板开始</a-tag>
<a-select style="width: 240px" placeholder="请选择模板" v-model="selectedTemplateId"
@change="handleTemplateChange" :loading="templatesLoading" :allowClear="false">
@change="handleTemplateChange" :loading="templatesLoading" :allowClear="true">
<a-select-option v-for="template in promptTemplates" :key="template.templateId"
:value="template.templateId">
{{ template.templateName }}
<a-tag v-if="template.isDefault == 1" color="green" size="small">默认</a-tag>
</a-select-option>
</a-select>
<span style="color: #666; font-size: 12px">选择模板可快速填充,下方可自定义编辑</span>
</a-space>
<!-- 模板管理按钮 -->
@@ -462,9 +508,11 @@
<a-textarea v-decorator="[
'roleDesc',
{
rules: [],
rules: [
{ required: true, message: '请输入角色提示词' },
],
},
]" :disabled="true" :rows="10" placeholder="请选择模板,提示词将随模板自动填充" />
]" :rows="10" placeholder="请选择模板开始,或直接输入自定义提示词" />
</a-form-item>
<!-- 表单操作按钮 -->
<a-form-item>
@@ -649,6 +697,11 @@ export default {
avatarUrl: '',
avatarLoading: false,
avatarFile: null,
// 背景图片相关
backgroundImageUrl: '',
backgroundLoading: false,
backgroundImageFile: null,
};
},
@@ -1279,9 +1332,9 @@ export default {
e.preventDefault();
this.roleForm.validateFields((err, values) => {
if (!err) {
// 校验必须选择模板
if (!this.selectedTemplateId) {
this.$message.warning('请选择模板');
// 校验roleDesc不为空
if (!values.roleDesc || !values.roleDesc.trim()) {
this.$message.warning('请输入角色提示词');
return;
}
this.submitLoading = true;
@@ -1290,8 +1343,9 @@ export default {
const formData = {
...values,
avatar: this.avatarUrl,
// 记录模板ID
templateId: this.selectedTemplateId,
backgroundImage: this.backgroundImageUrl,
// 记录模板ID可选用于统计和管理
templateId: this.selectedTemplateId || null,
// 将开关的布尔值转换为数字0或1
isDefault: values.isDefault ? 1 : 0
};
@@ -1342,6 +1396,8 @@ export default {
this.editingRoleDesc = record.roleDesc;
this.avatarUrl = record.avatar || ''; // 设置当前头像
this.avatarFile = null; // 清空文件对象,因为是编辑现有头像
this.backgroundImageUrl = record.backgroundImage || ''; // 设置当前背景图片
this.backgroundImageFile = null; // 清空文件对象,因为是编辑现有背景图片
// 切换到创建角色标签页
this.activeTabKey = '2';
@@ -1411,12 +1467,16 @@ export default {
ttsId: this.selectedTtsId,
voiceName: record.voiceName
});
// 如果存在模板ID填充模板内容
// 如果存在模板ID检查是否应该填充模板内容
// 只有当roleDesc与模板内容相同时才用模板内容填充保持向后兼容
if (this.selectedTemplateId && this.promptTemplates && this.promptTemplates.length > 0) {
const t = this.promptTemplates.find(t => t.templateId === this.selectedTemplateId);
if (t) {
if (t && record.roleDesc === t.templateContent) {
// 如果当前roleDesc与模板内容相同说明是基于模板创建的保持同步
roleForm.setFieldsValue({ roleDesc: t.templateContent });
}
// 如果roleDesc已被修改过保持用户的自定义内容不变
}
}, 500);
});
@@ -1484,6 +1544,8 @@ export default {
this.audioUrl = '';
this.avatarUrl = ''; // 重置头像
this.avatarFile = null; // 清空文件对象
this.backgroundImageUrl = ''; // 重置背景图片
this.backgroundImageFile = null; // 清空文件对象
// 应用默认值
this.applyDefaultValues();
@@ -1578,16 +1640,12 @@ export default {
}, 500);
});
// 如果有默认模板,应用默认模板
// 如果有默认模板,选择默认模板,但不自动填充内容
if (this.promptTemplates && this.promptTemplates.length > 0) {
const defaultTemplate = this.promptTemplates.find(t => t.isDefault == 1) || this.promptTemplates[0];
if (defaultTemplate) {
this.selectedTemplateId = defaultTemplate.templateId;
this.$nextTick(() => {
this.roleForm.setFieldsValue({
roleDesc: defaultTemplate.templateContent
});
});
// 不自动填充内容,用户可主动选择使用模板
}
}
},
@@ -1603,13 +1661,11 @@ export default {
.then(res => {
if (res.code === 200) {
this.promptTemplates = res.data.list;
// 自动选择默认模板或第一个
// 自动选择默认模板或第一个,但不自动填充内容(让用户主动选择)
const defaultTemplate = this.promptTemplates.find(t => t.isDefault == 1) || this.promptTemplates[0];
if (!this.selectedTemplateId && defaultTemplate) {
this.selectedTemplateId = defaultTemplate.templateId;
this.roleForm.setFieldsValue({
roleDesc: defaultTemplate.templateContent
});
// 不再自动填充内容,让用户主动选择是否使用模板
}
} else {
this.showError(res.message);
@@ -1759,15 +1815,40 @@ export default {
handleTemplateChange(templateId) {
const template = this.promptTemplates.find(t => t.templateId === templateId);
if (template) {
this.roleForm.setFieldsValue({
roleDesc: template.templateContent
});
// 获取当前文本框的值
const currentRoleDesc = this.roleForm.getFieldValue('roleDesc') || '';
// 如果文本框为空,或者内容是当前选中模板的内容(表示还未修改),直接填充
const isEmpty = !currentRoleDesc.trim();
const currentTemplate = this.selectedTemplateId && this.promptTemplates.find(t => t.templateId === this.selectedTemplateId);
const isCurrentTemplate = currentTemplate && currentTemplate.templateContent === currentRoleDesc;
if (isEmpty || isCurrentTemplate) {
// 直接填充模板内容
this.roleForm.setFieldsValue({
roleDesc: template.templateContent
});
} else {
// 有自定义内容,询问用户是否要替换
this.$confirm({
title: '确认替换提示词',
content: '您已经编辑了提示词内容,选择新模板将覆盖现有的编辑内容。确定要继续吗?',
onOk: () => {
this.roleForm.setFieldsValue({
roleDesc: template.templateContent
});
},
onCancel: () => {
// 用户取消保持原有内容但更新selectedTemplateId
}
});
}
}
},
// 跳转到模板管理页面
goToTemplateManager() {
this.$router.push('/template');
this.$router.push('/prompt-template');
},
// 获取语音显示名称
@@ -1900,6 +1981,83 @@ export default {
this.avatarUrl = '';
this.avatarFile = null;
},
// 背景图片上传前检查
beforeBackgroundUpload(file) {
const isImage = file.type.startsWith('image/');
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isImage) {
this.$message.error('只能上传图片文件!');
return false;
}
if (!isLt5M) {
this.$message.error('图片大小不能超过5MB!');
return false;
}
// 创建预览URL
this.backgroundImageFile = file;
// 立即上传图片
this.uploadBackgroundFile(file)
.then(url => {
this.backgroundImageUrl = url;
this.backgroundLoading = false;
})
.catch(error => {
this.$message.error('背景图片上传失败: ' + error);
this.backgroundLoading = false;
});
return false; // 阻止自动上传,我们会在提交表单时手动上传
},
// 上传背景图片文件并获取URL
uploadBackgroundFile(file) {
return new Promise((resolve, reject) => {
// 创建FormData对象
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'background'); // 指定上传类型为背景图片
// 使用XMLHttpRequest发送请求确保正确设置content-type
const xhr = new XMLHttpRequest();
xhr.open('POST', api.upload, true);
// 设置请求完成回调
xhr.onload = function () {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.code === 200) {
resolve(response.url);
} else {
reject(new Error(response.message || '上传失败'));
}
} catch (e) {
reject(new Error('解析响应失败'));
}
} else {
reject(new Error('上传失败,状态码: ' + xhr.status));
}
};
// 设置错误回调
xhr.onerror = function () {
reject(new Error('网络错误'));
};
// 发送请求
xhr.send(formData);
});
},
// 移除背景图片
removeBackgroundImage() {
this.backgroundImageUrl = '';
this.backgroundImageFile = null;
},
}
}
</script>
@@ -2030,4 +2188,100 @@ export default {
color: #8c8c8c;
font-size: 12px;
}
/* 背景图片上传样式 */
.background-uploader-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
/* 上传组件样式 */
.background-uploader {
cursor: pointer;
}
/* 上传内容区域 */
.background-content {
position: relative;
width: 200px;
height: 120px;
border-radius: 8px;
background-color: #fafafa;
border: 1px dashed #d9d9d9;
overflow: hidden;
transition: all 0.3s;
}
.background-content:hover {
border-color: #1890ff;
}
/* 背景图片 */
.background-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 占位符 */
.background-placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
color: #999;
}
.background-placeholder .anticon {
font-size: 32px;
margin-bottom: 8px;
}
.background-placeholder p {
margin: 0;
}
/* 悬浮遮罩 - 整个区域都显示 */
.background-hover-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
opacity: 0;
transition: opacity 0.3s;
}
.background-content:hover .background-hover-mask {
opacity: 1;
}
.background-hover-mask .anticon {
font-size: 24px;
margin-bottom: 8px;
}
.background-hover-mask p {
margin: 0;
}
/* 删除按钮 */
.background-remove-btn {
margin-top: 8px;
}
/* 提示文字 */
.background-tip {
margin-top: 8px;
color: #8c8c8c;
font-size: 12px;
}
</style>