Merge remote-tracking branch 'origin/dev2' into dev2
This commit is contained in:
commit
3c21fc3500
@ -24,10 +24,13 @@ import org.jeecg.modules.aiol.entity.AiolChatMember;
|
||||
import org.jeecg.modules.aiol.entity.AiolChatMessage;
|
||||
import org.jeecg.modules.aiol.entity.AiolClass;
|
||||
import org.jeecg.modules.aiol.entity.AiolClassStudent;
|
||||
import org.jeecg.modules.aiol.dto.ChatWithUnreadCountDTO;
|
||||
import org.jeecg.modules.aiol.mapper.AiolChatMemberMapper;
|
||||
import org.jeecg.modules.aiol.mapper.AiolChatMessageMapper;
|
||||
import org.jeecg.modules.aiol.mapper.AiolClassMapper;
|
||||
import org.jeecg.modules.aiol.mapper.AiolClassStudentMapper;
|
||||
import org.jeecg.modules.system.mapper.SysUserRoleMapper;
|
||||
import org.jeecg.modules.aiol.constant.RoleConst;
|
||||
import org.jeecg.modules.aiol.service.IAiolChatService;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.mapper.SysUserMapper;
|
||||
@ -85,6 +88,9 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
@Autowired
|
||||
private AiolClassStudentMapper aiolClassStudentMapper;
|
||||
|
||||
@Autowired
|
||||
private SysUserRoleMapper sysUserRoleMapper;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*
|
||||
@ -218,9 +224,9 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary = "查询当前用户会话列表", description = "根据当前登录用户ID查询其参与的会话列表")
|
||||
@Operation(summary = "查询当前用户会话列表", description = "根据当前登录用户ID查询其参与的会话列表,包含未读消息数")
|
||||
@GetMapping(value = "/my_chats")
|
||||
public Result<List<AiolChat>> queryMyChatList(HttpServletRequest request) {
|
||||
public Result<List<ChatWithUnreadCountDTO>> queryMyChatList(HttpServletRequest request) {
|
||||
try {
|
||||
// 1. 从token获取当前用户信息
|
||||
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
|
||||
@ -235,7 +241,7 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
return Result.error("用户信息不存在");
|
||||
}
|
||||
|
||||
// 2. 根据用户ID查询会话成员表,获取chat_id列表
|
||||
// 2. 根据用户ID查询会话成员表,获取chat_id列表和last_read_msg_id
|
||||
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
|
||||
memberWrapper.eq("user_id", sysUser.getId());
|
||||
List<AiolChatMember> chatMembers = aiolChatMemberMapper.selectList(memberWrapper);
|
||||
@ -256,37 +262,16 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
|
||||
List<AiolChat> chatList = aiolChatService.list(chatWrapper);
|
||||
|
||||
// 5. 处理私聊类型的会话,获取对方用户信息
|
||||
// 5. 转换为包含未读消息数的DTO列表
|
||||
List<ChatWithUnreadCountDTO> resultList = new java.util.ArrayList<>();
|
||||
|
||||
for (AiolChat chat : chatList) {
|
||||
if (chat.getType() != null && chat.getType() == 0) {
|
||||
// 私聊类型,需要获取对方用户信息
|
||||
try {
|
||||
// 查询该会话的成员,排除当前用户
|
||||
QueryWrapper<AiolChatMember> otherMemberWrapper = new QueryWrapper<>();
|
||||
otherMemberWrapper.eq("chat_id", chat.getId())
|
||||
.ne("user_id", sysUser.getId());
|
||||
List<AiolChatMember> otherMembers = aiolChatMemberMapper.selectList(otherMemberWrapper);
|
||||
|
||||
if (!otherMembers.isEmpty()) {
|
||||
// 获取对方用户ID
|
||||
String otherUserId = otherMembers.get(0).getUserId();
|
||||
|
||||
// 查询对方用户信息
|
||||
SysUser otherUser = sysUserMapper.selectById(otherUserId);
|
||||
if (otherUser != null) {
|
||||
// 替换会话的name和avatar为对方用户信息
|
||||
chat.setName(otherUser.getRealname());
|
||||
chat.setAvatar(otherUser.getAvatar());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取私聊对方用户信息失败: chatId={}, error={}", chat.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
ChatWithUnreadCountDTO chatDTO = convertToChatWithUnreadCount(chat, chatMembers, sysUser.getId());
|
||||
resultList.add(chatDTO);
|
||||
}
|
||||
|
||||
log.info("用户 {} 查询到 {} 个会话", username, chatList.size());
|
||||
return Result.OK(chatList);
|
||||
log.info("用户 {} 查询到 {} 个会话", username, resultList.size());
|
||||
return Result.OK(resultList);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("查询用户会话列表失败: {}", e.getMessage(), e);
|
||||
@ -300,7 +285,7 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
* @param chatId 会话ID
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary = "查询群聊会话成员列表", description = "根据会话ID查询该会话的所有成员信息,包括用户ID、真实姓名和头像")
|
||||
@Operation(summary = "查询群聊会话成员列表", description = "根据会话ID查询该会话的所有成员信息,包括用户ID、真实姓名、头像和教师身份")
|
||||
@GetMapping(value = "/{chatId}/members")
|
||||
public Result<List<Map<String, Object>>> queryChatMembers(@PathVariable(value = "chatId") String chatId) {
|
||||
try {
|
||||
@ -321,7 +306,26 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
// 3. 查询用户信息
|
||||
List<SysUser> userList = sysUserMapper.selectByIds(userIds);
|
||||
|
||||
// 4. 构建返回结果
|
||||
// 4. 查询所有用户的教师角色身份
|
||||
Map<String, Boolean> teacherStatusMap = new java.util.HashMap<>();
|
||||
if (!userIds.isEmpty()) {
|
||||
try {
|
||||
QueryWrapper<org.jeecg.modules.system.entity.SysUserRole> roleWrapper = new QueryWrapper<>();
|
||||
roleWrapper.eq("role_id", RoleConst.TEACHER_ROLE_ID)
|
||||
.in("user_id", userIds);
|
||||
|
||||
List<org.jeecg.modules.system.entity.SysUserRole> teacherRoleList = sysUserRoleMapper.selectList(roleWrapper);
|
||||
|
||||
// 构建教师身份映射
|
||||
for (org.jeecg.modules.system.entity.SysUserRole userRole : teacherRoleList) {
|
||||
teacherStatusMap.put(userRole.getUserId(), true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("查询教师角色身份失败: error={}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 构建返回结果
|
||||
List<Map<String, Object>> result = new java.util.ArrayList<>();
|
||||
for (SysUser user : userList) {
|
||||
Map<String, Object> memberInfo = new java.util.HashMap<>();
|
||||
@ -329,6 +333,10 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
memberInfo.put("realname", user.getRealname());
|
||||
memberInfo.put("avatar", user.getAvatar());
|
||||
|
||||
// 添加教师身份标记
|
||||
boolean isTeacher = teacherStatusMap.getOrDefault(user.getId(), false);
|
||||
memberInfo.put("isTeacher", isTeacher);
|
||||
|
||||
// 可选:添加更多用户信息
|
||||
memberInfo.put("username", user.getUsername());
|
||||
memberInfo.put("phone", user.getPhone());
|
||||
@ -502,4 +510,458 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
|
||||
return Result.error("查询会话详情失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启全员禁言
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-开启全员禁言")
|
||||
@Operation(summary = "开启全员禁言", description = "开启群聊的全员禁言功能,设置iz_all_muted字段为1")
|
||||
@PostMapping(value = "/{chatId}/mute_all")
|
||||
public Result<String> muteAll(@PathVariable(value = "chatId") String chatId) {
|
||||
try {
|
||||
return updateChatSetting(chatId, "iz_all_muted", 1, "开启全员禁言");
|
||||
} catch (Exception e) {
|
||||
log.error("开启全员禁言失败: chatId={}, error={}", chatId, e.getMessage(), e);
|
||||
return Result.error("开启全员禁言失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭全员禁言
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-关闭全员禁言")
|
||||
@Operation(summary = "关闭全员禁言", description = "关闭群聊的全员禁言功能,设置iz_all_muted字段为0")
|
||||
@PostMapping(value = "/{chatId}/unmute_all")
|
||||
public Result<String> unmuteAll(@PathVariable(value = "chatId") String chatId) {
|
||||
try {
|
||||
return updateChatSetting(chatId, "iz_all_muted", 0, "关闭全员禁言");
|
||||
} catch (Exception e) {
|
||||
log.error("关闭全员禁言失败: chatId={}, error={}", chatId, e.getMessage(), e);
|
||||
return Result.error("关闭全员禁言失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启显示教师标签
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-开启显示教师标签")
|
||||
@Operation(summary = "开启显示教师标签", description = "开启群聊中显示教师标签功能,设置show_label字段为1")
|
||||
@PostMapping(value = "/{chatId}/show_label")
|
||||
public Result<String> showLabel(@PathVariable(value = "chatId") String chatId) {
|
||||
try {
|
||||
return updateChatSetting(chatId, "show_label", 1, "开启显示教师标签");
|
||||
} catch (Exception e) {
|
||||
log.error("开启显示教师标签失败: chatId={}, error={}", chatId, e.getMessage(), e);
|
||||
return Result.error("开启显示教师标签失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭显示教师标签
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-关闭显示教师标签")
|
||||
@Operation(summary = "关闭显示教师标签", description = "关闭群聊中显示教师标签功能,设置show_label字段为0")
|
||||
@PostMapping(value = "/{chatId}/hide_label")
|
||||
public Result<String> hideLabel(@PathVariable(value = "chatId") String chatId) {
|
||||
try {
|
||||
return updateChatSetting(chatId, "show_label", 0, "关闭显示教师标签");
|
||||
} catch (Exception e) {
|
||||
log.error("关闭显示教师标签失败: chatId={}, error={}", chatId, e.getMessage(), e);
|
||||
return Result.error("关闭显示教师标签失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用更新会话设置的方法
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param fieldName 字段名
|
||||
* @param value 字段值
|
||||
* @param operationName 操作名称
|
||||
* @return
|
||||
*/
|
||||
private Result<String> updateChatSetting(String chatId, String fieldName, Integer value, String operationName) {
|
||||
// 1. 查询会话是否存在
|
||||
AiolChat chat = aiolChatService.getById(chatId);
|
||||
if (chat == null) {
|
||||
return Result.error("会话不存在");
|
||||
}
|
||||
|
||||
// 2. 根据字段名设置相应的值
|
||||
if ("iz_all_muted".equals(fieldName)) {
|
||||
chat.setIzAllMuted(value);
|
||||
} else if ("show_label".equals(fieldName)) {
|
||||
chat.setShowLabel(value);
|
||||
} else {
|
||||
return Result.error("无效的字段名");
|
||||
}
|
||||
|
||||
// 3. 更新数据库
|
||||
boolean updated = aiolChatService.updateById(chat);
|
||||
if (!updated) {
|
||||
return Result.error(operationName + "失败");
|
||||
}
|
||||
|
||||
log.info("{}成功: chatId={}, {}={}", operationName, chatId, fieldName, value);
|
||||
return Result.OK(operationName + "成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁言群聊成员
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-禁言群聊成员")
|
||||
@Operation(summary = "禁言群聊成员", description = "禁言指定群聊成员,设置iz_muted字段为1")
|
||||
@PostMapping(value = "/{chatId}/mute_member/{userId}")
|
||||
public Result<String> muteMember(@PathVariable(value = "chatId") String chatId,
|
||||
@PathVariable(value = "userId") String userId) {
|
||||
try {
|
||||
return updateMemberSetting(chatId, userId, "iz_muted", 1, "禁言群聊成员");
|
||||
} catch (Exception e) {
|
||||
log.error("禁言群聊成员失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
|
||||
return Result.error("禁言群聊成员失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除禁言群聊成员
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-解除禁言群聊成员")
|
||||
@Operation(summary = "解除禁言群聊成员", description = "解除指定群聊成员的禁言状态,设置iz_muted字段为0")
|
||||
@PostMapping(value = "/{chatId}/unmute_member/{userId}")
|
||||
public Result<String> unmuteMember(@PathVariable(value = "chatId") String chatId,
|
||||
@PathVariable(value = "userId") String userId) {
|
||||
try {
|
||||
return updateMemberSetting(chatId, userId, "iz_muted", 0, "解除禁言群聊成员");
|
||||
} catch (Exception e) {
|
||||
log.error("解除禁言群聊成员失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
|
||||
return Result.error("解除禁言群聊成员失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启免打扰
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-开启免打扰")
|
||||
@Operation(summary = "开启免打扰", description = "为指定用户开启群聊免打扰功能,设置iz_not_disturb字段为1")
|
||||
@PostMapping(value = "/{chatId}/enable_not_disturb/{userId}")
|
||||
public Result<String> enableNotDisturb(@PathVariable(value = "chatId") String chatId,
|
||||
@PathVariable(value = "userId") String userId) {
|
||||
try {
|
||||
return updateMemberSetting(chatId, userId, "iz_not_disturb", 1, "开启免打扰");
|
||||
} catch (Exception e) {
|
||||
log.error("开启免打扰失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
|
||||
return Result.error("开启免打扰失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭免打扰
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-关闭免打扰")
|
||||
@Operation(summary = "关闭免打扰", description = "为指定用户关闭群聊免打扰功能,设置iz_not_disturb字段为0")
|
||||
@PostMapping(value = "/{chatId}/disable_not_disturb/{userId}")
|
||||
public Result<String> disableNotDisturb(@PathVariable(value = "chatId") String chatId,
|
||||
@PathVariable(value = "userId") String userId) {
|
||||
try {
|
||||
return updateMemberSetting(chatId, userId, "iz_not_disturb", 0, "关闭免打扰");
|
||||
} catch (Exception e) {
|
||||
log.error("关闭免打扰失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
|
||||
return Result.error("关闭免打扰失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用更新群聊成员设置的方法
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @param fieldName 字段名
|
||||
* @param value 字段值
|
||||
* @param operationName 操作名称
|
||||
* @return
|
||||
*/
|
||||
private Result<String> updateMemberSetting(String chatId, String userId, String fieldName, Integer value, String operationName) {
|
||||
// 1. 查询群聊成员是否存在
|
||||
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
|
||||
memberWrapper.eq("chat_id", chatId).eq("user_id", userId);
|
||||
AiolChatMember chatMember = aiolChatMemberMapper.selectOne(memberWrapper);
|
||||
|
||||
if (chatMember == null) {
|
||||
return Result.error("该用户不是群聊成员或会话不存在");
|
||||
}
|
||||
|
||||
// 2. 根据字段名设置相应的值
|
||||
if ("iz_muted".equals(fieldName)) {
|
||||
chatMember.setIzMuted(value);
|
||||
} else if ("iz_not_disturb".equals(fieldName)) {
|
||||
chatMember.setIzNotDisturb(value);
|
||||
} else {
|
||||
return Result.error("无效的字段名");
|
||||
}
|
||||
|
||||
// 3. 更新数据库
|
||||
boolean updated = aiolChatMemberMapper.updateById(chatMember) > 0;
|
||||
if (!updated) {
|
||||
return Result.error(operationName + "失败");
|
||||
}
|
||||
|
||||
log.info("{}成功: chatId={}, userId={}, {}={}", operationName, chatId, userId, fieldName, value);
|
||||
return Result.OK(operationName + "成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将AiolChat转换为包含未读消息数的DTO
|
||||
* @param chat 会话实体
|
||||
* @param chatMembers 会话成员列表
|
||||
* @param userId 当前用户ID
|
||||
* @return 包含未读消息数的DTO
|
||||
*/
|
||||
private ChatWithUnreadCountDTO convertToChatWithUnreadCount(AiolChat chat, List<AiolChatMember> chatMembers, String userId) {
|
||||
ChatWithUnreadCountDTO chatDTO = new ChatWithUnreadCountDTO();
|
||||
|
||||
// 复制基本属性
|
||||
chatDTO.setId(chat.getId());
|
||||
chatDTO.setType(chat.getType());
|
||||
chatDTO.setName(chat.getName());
|
||||
chatDTO.setAvatar(chat.getAvatar());
|
||||
chatDTO.setRefId(chat.getRefId());
|
||||
chatDTO.setIzAllMuted(chat.getIzAllMuted());
|
||||
chatDTO.setShowLabel(chat.getShowLabel());
|
||||
chatDTO.setCreateBy(chat.getCreateBy());
|
||||
chatDTO.setCreateTime(chat.getCreateTime());
|
||||
chatDTO.setUpdateBy(chat.getUpdateBy());
|
||||
chatDTO.setUpdateTime(chat.getUpdateTime());
|
||||
|
||||
try {
|
||||
// 1. 计算未读消息数
|
||||
AiolChatMember currentUserMember = chatMembers.stream()
|
||||
.filter(member -> member.getChatId().equals(chat.getId()) && member.getUserId().equals(userId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
int unreadCount = 0;
|
||||
if (currentUserMember != null) {
|
||||
String lastReadMsgId = currentUserMember.getLastReadMsgId();
|
||||
|
||||
// 查询该会话中last_read_msg_id之后的消息数量
|
||||
QueryWrapper<AiolChatMessage> messageWrapper = new QueryWrapper<>();
|
||||
messageWrapper.eq("chat_id", chat.getId());
|
||||
|
||||
if (lastReadMsgId != null && !lastReadMsgId.trim().isEmpty()) {
|
||||
// 如果有最后读取的消息ID,查询该消息之后的消息
|
||||
QueryWrapper<AiolChatMessage> lastReadMsgWrapper = new QueryWrapper<>();
|
||||
lastReadMsgWrapper.eq("chat_id", chat.getId()).eq("id", lastReadMsgId);
|
||||
AiolChatMessage lastReadMsg = aiolChatMessageMapper.selectOne(lastReadMsgWrapper);
|
||||
|
||||
if (lastReadMsg != null) {
|
||||
// 查询创建时间晚于最后读取消息的消息数量
|
||||
messageWrapper.gt("create_time", lastReadMsg.getCreateTime());
|
||||
}
|
||||
}
|
||||
|
||||
Long count = aiolChatMessageMapper.selectCount(messageWrapper);
|
||||
unreadCount = count != null ? count.intValue() : 0;
|
||||
}
|
||||
|
||||
chatDTO.setUnreadCount(unreadCount);
|
||||
|
||||
// 2. 处理私聊类型的会话,获取对方用户信息
|
||||
if (chat.getType() != null && chat.getType() == 0) {
|
||||
// 私聊类型,需要获取对方用户信息
|
||||
try {
|
||||
// 查询该会话的成员,排除当前用户
|
||||
QueryWrapper<AiolChatMember> otherMemberWrapper = new QueryWrapper<>();
|
||||
otherMemberWrapper.eq("chat_id", chat.getId())
|
||||
.ne("user_id", userId);
|
||||
List<AiolChatMember> otherMembers = aiolChatMemberMapper.selectList(otherMemberWrapper);
|
||||
|
||||
if (!otherMembers.isEmpty()) {
|
||||
// 获取对方用户ID
|
||||
String otherUserId = otherMembers.get(0).getUserId();
|
||||
|
||||
// 查询对方用户信息
|
||||
SysUser otherUser = sysUserMapper.selectById(otherUserId);
|
||||
if (otherUser != null) {
|
||||
// 替换会话的name和avatar为对方用户信息
|
||||
chatDTO.setName(otherUser.getRealname());
|
||||
chatDTO.setAvatar(otherUser.getAvatar());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取私聊对方用户信息失败: chatId={}, error={}", chat.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("转换会话信息失败: chatId={}, error={}", chat.getId(), e.getMessage(), e);
|
||||
// 即使转换失败,也返回基本的会话信息
|
||||
chatDTO.setUnreadCount(0);
|
||||
}
|
||||
|
||||
return chatDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户最后读取的消息ID
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param messageId 消息ID
|
||||
* @param request HTTP请求对象
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-更新最后读取消息ID")
|
||||
@Operation(summary = "更新最后读取消息ID", description = "更新当前用户在指定会话中的最后读取消息ID")
|
||||
@PostMapping(value = "/{chatId}/update_last_read/{messageId}")
|
||||
public Result<String> updateLastReadMsgId(@PathVariable(value = "chatId") String chatId,
|
||||
@PathVariable(value = "messageId") String messageId,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
// 1. 从token获取当前用户信息
|
||||
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
return Result.error("用户未登录");
|
||||
}
|
||||
|
||||
String username = JwtUtil.getUsername(token);
|
||||
LoginUser sysUser = sysBaseApi.getUserByName(username);
|
||||
|
||||
if (sysUser == null) {
|
||||
return Result.error("用户信息不存在");
|
||||
}
|
||||
|
||||
// 2. 查询群聊成员是否存在
|
||||
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
|
||||
memberWrapper.eq("chat_id", chatId).eq("user_id", sysUser.getId());
|
||||
AiolChatMember chatMember = aiolChatMemberMapper.selectOne(memberWrapper);
|
||||
|
||||
if (chatMember == null) {
|
||||
return Result.error("该用户不是群聊成员或会话不存在");
|
||||
}
|
||||
|
||||
// 3. 验证消息是否存在
|
||||
AiolChatMessage message = aiolChatMessageMapper.selectById(messageId);
|
||||
if (message == null || !message.getChatId().equals(chatId)) {
|
||||
return Result.error("消息不存在或不属于该会话");
|
||||
}
|
||||
|
||||
// 4. 更新最后读取的消息ID
|
||||
chatMember.setLastReadMsgId(messageId);
|
||||
boolean updated = aiolChatMemberMapper.updateById(chatMember) > 0;
|
||||
|
||||
if (!updated) {
|
||||
return Result.error("更新失败");
|
||||
}
|
||||
|
||||
log.info("用户 {} 更新会话 {} 的最后读取消息ID为 {}", username, chatId, messageId);
|
||||
return Result.OK("更新成功!");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("更新最后读取消息ID失败: chatId={}, messageId={}, error={}", chatId, messageId, e.getMessage(), e);
|
||||
return Result.error("更新失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出群聊
|
||||
*
|
||||
* @param chatId 会话ID
|
||||
* @param request HTTP请求对象
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "会话-退出群聊")
|
||||
@Operation(summary = "退出群聊", description = "当前用户退出指定的群聊会话,从aiol_chat_member表中删除用户记录")
|
||||
@DeleteMapping(value = "/{chatId}/exit")
|
||||
public Result<String> exitChat(@PathVariable(value = "chatId") String chatId,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
// 1. 从token获取当前用户信息
|
||||
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
return Result.error("用户未登录");
|
||||
}
|
||||
|
||||
String username = JwtUtil.getUsername(token);
|
||||
LoginUser sysUser = sysBaseApi.getUserByName(username);
|
||||
|
||||
if (sysUser == null) {
|
||||
return Result.error("用户信息不存在");
|
||||
}
|
||||
|
||||
// 2. 验证会话是否存在
|
||||
AiolChat chat = aiolChatService.getById(chatId);
|
||||
if (chat == null) {
|
||||
return Result.error("会话不存在");
|
||||
}
|
||||
|
||||
// 3. 查询用户是否为该会话的成员
|
||||
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
|
||||
memberWrapper.eq("chat_id", chatId).eq("user_id", sysUser.getId());
|
||||
AiolChatMember chatMember = aiolChatMemberMapper.selectOne(memberWrapper);
|
||||
|
||||
if (chatMember == null) {
|
||||
return Result.error("您不是该会话的成员");
|
||||
}
|
||||
|
||||
// 4. 检查是否为会话创建者(可选:不允许创建者退出)
|
||||
if (chat.getCreateBy() != null && chat.getCreateBy().equals(sysUser.getId())) {
|
||||
// 可选:如果创建者退出,需要转移创建者权限或不允许退出
|
||||
log.warn("会话创建者尝试退出群聊: chatId={}, userId={}", chatId, sysUser.getId());
|
||||
// 这里可以选择是否允许创建者退出,或者需要先转移权限
|
||||
// return Result.error("群聊创建者不能直接退出,请先转移群主权限");
|
||||
}
|
||||
|
||||
// 5. 从会话成员表中删除该用户
|
||||
boolean deleted = aiolChatMemberMapper.deleteById(chatMember.getId()) > 0;
|
||||
|
||||
if (!deleted) {
|
||||
return Result.error("退出群聊失败");
|
||||
}
|
||||
|
||||
// 6. 检查会话是否还有其他成员,如果没有则删除会话(可选)
|
||||
QueryWrapper<AiolChatMember> remainingMemberWrapper = new QueryWrapper<>();
|
||||
remainingMemberWrapper.eq("chat_id", chatId);
|
||||
long remainingMemberCount = aiolChatMemberMapper.selectCount(remainingMemberWrapper);
|
||||
|
||||
if (remainingMemberCount == 0) {
|
||||
// 如果没有其他成员了,删除会话
|
||||
aiolChatService.removeById(chatId);
|
||||
log.info("会话 {} 无成员,已自动删除", chatId);
|
||||
}
|
||||
|
||||
log.info("用户 {} 成功退出群聊 {}", username, chatId);
|
||||
return Result.OK("退出群聊成功!");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("退出群聊失败: chatId={}, error={}", chatId, e.getMessage(), e);
|
||||
return Result.error("退出群聊失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -365,4 +365,33 @@ public class AiolCommentController extends JeecgController<AiolComment, IAiolCom
|
||||
return Result.error("点赞失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@AutoLog(value = "评论-置顶")
|
||||
@Operation(summary = "置顶评论", description = "将指定评论置顶,设置iz_top字段为1")
|
||||
@RequiresPermissions("aiol:aiol_comment:edit")
|
||||
@GetMapping(value = "/top/{commentId}")
|
||||
public Result<String> topComment(@PathVariable("commentId") String commentId) {
|
||||
try {
|
||||
// 1. 查询评论是否存在
|
||||
AiolComment comment = aiolCommentService.getById(commentId);
|
||||
if (comment == null) {
|
||||
return Result.error("评论不存在");
|
||||
}
|
||||
|
||||
// 2. 设置置顶状态
|
||||
comment.setIzTop(1);
|
||||
|
||||
boolean updated = aiolCommentService.updateById(comment);
|
||||
if (!updated) {
|
||||
return Result.error("置顶失败");
|
||||
}
|
||||
|
||||
log.info("评论置顶成功: commentId={}", commentId);
|
||||
return Result.OK("置顶成功!");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("评论置顶失败: commentId={}, error={}", commentId, e.getMessage(), e);
|
||||
return Result.error("置顶失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,8 +16,12 @@ import org.jeecg.common.system.query.QueryRuleEnum;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.config.shiro.IgnoreAuth;
|
||||
import org.jeecg.modules.aiol.entity.AiolDiscussion;
|
||||
import org.jeecg.modules.aiol.dto.DiscussionWithSectionDTO;
|
||||
import org.jeecg.modules.aiol.dto.AiolDiscussionSaveDTO;
|
||||
import org.jeecg.modules.aiol.service.IAiolDiscussionService;
|
||||
import org.jeecg.modules.aiol.service.IAiolEntityLinkService;
|
||||
import org.jeecg.modules.aiol.service.IAiolCourseSectionService;
|
||||
import org.jeecg.modules.aiol.entity.AiolCourseSection;
|
||||
import org.jeecg.modules.aiol.constant.EntityLinkConst;
|
||||
import org.jeecg.common.system.util.JwtUtil;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
@ -61,6 +65,8 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
|
||||
@Autowired
|
||||
private IAiolEntityLinkService aiolEntityLinkService;
|
||||
@Autowired
|
||||
private IAiolCourseSectionService aiolCourseSectionService;
|
||||
@Autowired
|
||||
private ISysBaseAPI sysBaseApi;
|
||||
|
||||
/**
|
||||
@ -90,21 +96,26 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
|
||||
/**
|
||||
* 添加
|
||||
*
|
||||
* @param aiolDiscussion
|
||||
* @param sectionId 可选的章节ID,如果传入则创建与章节的关联关系
|
||||
* @param aiolDiscussionSaveDTO 讨论保存DTO,包含章节ID
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "讨论-添加")
|
||||
@Operation(summary="讨论-添加", description="添加讨论,可选择关联到指定章节")
|
||||
@RequiresPermissions("aiol:aiol_discussion:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<String> add(@RequestBody AiolDiscussion aiolDiscussion,
|
||||
@RequestParam(value = "sectionId", required = false) String sectionId) {
|
||||
public Result<String> add(@RequestBody AiolDiscussionSaveDTO aiolDiscussionSaveDTO) {
|
||||
try {
|
||||
// 创建讨论实体
|
||||
AiolDiscussion aiolDiscussion = new AiolDiscussion();
|
||||
aiolDiscussion.setTitle(aiolDiscussionSaveDTO.getTitle());
|
||||
aiolDiscussion.setDescription(aiolDiscussionSaveDTO.getDescription());
|
||||
aiolDiscussion.setCourseId(aiolDiscussionSaveDTO.getCourseId());
|
||||
|
||||
// 保存讨论记录
|
||||
aiolDiscussionService.save(aiolDiscussion);
|
||||
|
||||
// 如果传入了sectionId,创建与章节的关联关系
|
||||
String sectionId = aiolDiscussionSaveDTO.getSectionId();
|
||||
if (sectionId != null && !sectionId.trim().isEmpty()) {
|
||||
aiolEntityLinkService.save(
|
||||
EntityLinkConst.SourceType.COURSE_SECTION,
|
||||
@ -115,7 +126,7 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
|
||||
log.info("讨论 {} 成功关联到章节 {}", aiolDiscussion.getId(), sectionId);
|
||||
}
|
||||
|
||||
return Result.OK("添加成功!");
|
||||
return Result.OK(aiolDiscussion.getId());
|
||||
} catch (Exception e) {
|
||||
log.error("添加讨论失败: {}", e.getMessage(), e);
|
||||
return Result.error("添加讨论失败: " + e.getMessage());
|
||||
@ -211,22 +222,71 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
|
||||
}
|
||||
|
||||
@AutoLog(value = "讨论-查询课程下的讨论列表")
|
||||
@Operation(summary = "查询课程讨论列表", description = "查询课程讨论列表")
|
||||
@Operation(summary = "查询课程讨论列表", description = "查询课程讨论列表,包含关联的章节ID")
|
||||
@GetMapping(value = "/teacher_list")
|
||||
public Result<List<AiolDiscussion>> queryCourseDiscussions(HttpServletRequest request, @RequestParam(value = "courseId") String courseId) {
|
||||
public Result<List<DiscussionWithSectionDTO>> queryCourseDiscussions(HttpServletRequest request, @RequestParam(value = "courseId") String courseId) {
|
||||
try {
|
||||
// 2. 查询课程下的讨论列表
|
||||
// 1. 查询课程下的讨论列表
|
||||
QueryWrapper<AiolDiscussion> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("course_id", courseId)
|
||||
.orderByDesc("create_time");
|
||||
|
||||
List<AiolDiscussion> discussionList = aiolDiscussionService.list(queryWrapper);
|
||||
|
||||
return Result.OK(discussionList);
|
||||
// 2. 转换为包含章节信息的DTO列表
|
||||
List<DiscussionWithSectionDTO> resultList = discussionList.stream()
|
||||
.map(this::convertToDiscussionWithSection)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Result.OK(resultList);
|
||||
} catch (Exception e) {
|
||||
log.error("查询用户讨论列表失败: error={}", e.getMessage(), e);
|
||||
return Result.error("查询用户讨论列表失败: " + e.getMessage());
|
||||
log.error("查询课程讨论列表失败: error={}", e.getMessage(), e);
|
||||
return Result.error("查询课程讨论列表失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将AiolDiscussion转换为包含章节信息的DTO
|
||||
* @param discussion 讨论实体
|
||||
* @return 包含章节ID的DTO
|
||||
*/
|
||||
private DiscussionWithSectionDTO convertToDiscussionWithSection(AiolDiscussion discussion) {
|
||||
DiscussionWithSectionDTO dto = new DiscussionWithSectionDTO();
|
||||
|
||||
// 复制基本属性
|
||||
dto.setId(discussion.getId());
|
||||
dto.setTitle(discussion.getTitle());
|
||||
dto.setDescription(discussion.getDescription());
|
||||
dto.setCourseId(discussion.getCourseId());
|
||||
dto.setCreateBy(discussion.getCreateBy());
|
||||
dto.setCreateTime(discussion.getCreateTime());
|
||||
dto.setUpdateBy(discussion.getUpdateBy());
|
||||
dto.setUpdateTime(discussion.getUpdateTime());
|
||||
|
||||
try {
|
||||
// 查询关联的章节ID
|
||||
String sectionId = aiolEntityLinkService.listSourceId(
|
||||
EntityLinkConst.TargetType.DISCUSSION,
|
||||
discussion.getId(),
|
||||
EntityLinkConst.SourceType.COURSE_SECTION
|
||||
);
|
||||
|
||||
if (sectionId != null) {
|
||||
dto.setSectionId(sectionId);
|
||||
|
||||
// 查询章节名称
|
||||
AiolCourseSection section = aiolCourseSectionService.getById(sectionId);
|
||||
if (section != null && section.getName() != null) {
|
||||
dto.setSectionName(section.getName());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("查询讨论关联章节失败: discussionId={}, error={}", discussion.getId(), e.getMessage(), e);
|
||||
// 即使查询失败,也返回基本的讨论信息
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -22,10 +22,15 @@ import org.jeecg.common.constant.CommonConstant;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.aiol.dto.AiolHomeworkSaveDTO;
|
||||
import org.jeecg.modules.aiol.dto.StudentSubmitHomework;
|
||||
import org.jeecg.modules.aiol.dto.HomeworkWithDetailsDTO;
|
||||
import org.jeecg.modules.aiol.entity.AiolHomework;
|
||||
import org.jeecg.modules.aiol.entity.AiolHomeworkSubmit;
|
||||
import org.jeecg.modules.aiol.entity.AiolClass;
|
||||
import org.jeecg.modules.aiol.entity.AiolCourseSection;
|
||||
import org.jeecg.modules.aiol.service.IAiolHomeworkService;
|
||||
import org.jeecg.modules.aiol.service.IAiolEntityLinkService;
|
||||
import org.jeecg.modules.aiol.service.IAiolClassService;
|
||||
import org.jeecg.modules.aiol.service.IAiolCourseSectionService;
|
||||
import org.jeecg.modules.aiol.constant.EntityLinkConst;
|
||||
import org.jeecg.modules.aiol.mapper.AiolClassStudentMapper;
|
||||
import org.jeecg.modules.aiol.mapper.AiolCourseSignupMapper;
|
||||
@ -162,14 +167,17 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
|
||||
* @return
|
||||
*/
|
||||
//@AutoLog(value = "作业-通过id查询")
|
||||
@Operation(summary = "作业-通过id查询")
|
||||
@Operation(summary = "作业-通过id查询", description = "根据ID查询作业详情,包含班级名和章节信息")
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<AiolHomework> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
public Result<HomeworkWithDetailsDTO> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiolHomework aiolHomework = aiolHomeworkService.getById(id);
|
||||
if (aiolHomework == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
return Result.OK(aiolHomework);
|
||||
|
||||
// 转换为包含详情的DTO
|
||||
HomeworkWithDetailsDTO result = convertToHomeworkWithDetails(aiolHomework);
|
||||
return Result.OK(result);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -212,6 +220,12 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
|
||||
@Autowired
|
||||
private ISysBaseAPI sysBaseApi;
|
||||
|
||||
@Autowired
|
||||
private IAiolClassService aiolClassService;
|
||||
|
||||
@Autowired
|
||||
private IAiolCourseSectionService aiolCourseSectionService;
|
||||
|
||||
@GetMapping("/course/{courseId}")
|
||||
@Operation(summary = "查询课程作业")
|
||||
public Result<List<AiolHomework>> list(@PathVariable String courseId) {
|
||||
@ -219,9 +233,9 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
|
||||
}
|
||||
|
||||
@AutoLog(value = "作业-教师端查询作业列表")
|
||||
@Operation(summary = "查询课程作业列表", description = "查询当前登录教师创建的作业列表")
|
||||
@Operation(summary = "查询课程作业列表", description = "查询当前登录教师创建的作业列表,包含班级名和章节信息")
|
||||
@GetMapping(value = "/teacher_list")
|
||||
public Result<List<AiolHomework>> teacherList(@RequestParam(value = "courseId") String courseId, HttpServletRequest request) {
|
||||
public Result<List<HomeworkWithDetailsDTO>> teacherList(@RequestParam(value = "courseId") String courseId, HttpServletRequest request) {
|
||||
try {
|
||||
QueryWrapper<AiolHomework> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("course_id", courseId)
|
||||
@ -229,7 +243,12 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
|
||||
|
||||
List<AiolHomework> homeworkList = aiolHomeworkService.list(queryWrapper);
|
||||
|
||||
return Result.OK(homeworkList);
|
||||
// 转换为包含详情的DTO列表
|
||||
List<HomeworkWithDetailsDTO> resultList = homeworkList.stream()
|
||||
.map(this::convertToHomeworkWithDetails)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Result.OK(resultList);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("查询教师作业列表失败: error={}", e.getMessage(), e);
|
||||
@ -422,4 +441,56 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
|
||||
return Result.error("批阅失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将AiolHomework转换为包含详情的DTO
|
||||
* @param homework 作业实体
|
||||
* @return 包含班级名和章节信息的DTO
|
||||
*/
|
||||
private HomeworkWithDetailsDTO convertToHomeworkWithDetails(AiolHomework homework) {
|
||||
HomeworkWithDetailsDTO dto = new HomeworkWithDetailsDTO();
|
||||
BeanUtils.copyProperties(homework, dto);
|
||||
|
||||
try {
|
||||
// 1. 查询班级信息
|
||||
List<String> classNames = new ArrayList<>();
|
||||
String classId = homework.getClassId();
|
||||
if (classId != null && !classId.trim().isEmpty()) {
|
||||
String[] classIds = classId.split(",");
|
||||
for (String singleClassId : classIds) {
|
||||
if (singleClassId != null && !singleClassId.trim().isEmpty()) {
|
||||
singleClassId = singleClassId.trim();
|
||||
AiolClass aiolClass = aiolClassService.getById(singleClassId);
|
||||
if (aiolClass != null && aiolClass.getName() != null) {
|
||||
classNames.add(aiolClass.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dto.setClassNames(classNames);
|
||||
|
||||
// 2. 查询章节信息
|
||||
List<String> sectionIds = entityLinkBizService.listSourceIds(
|
||||
EntityLinkConst.TargetType.HOMEWORK,
|
||||
homework.getId(),
|
||||
EntityLinkConst.SourceType.COURSE_SECTION
|
||||
);
|
||||
|
||||
if (!sectionIds.isEmpty()) {
|
||||
String sectionId = sectionIds.get(0); // 取第一个章节ID
|
||||
dto.setSectionId(sectionId);
|
||||
|
||||
AiolCourseSection section = aiolCourseSectionService.getById(sectionId);
|
||||
if (section != null && section.getName() != null) {
|
||||
dto.setSectionTitle(section.getName());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("转换作业详情失败: homeworkId={}, error={}", homework.getId(), e.getMessage(), e);
|
||||
// 即使转换失败,也返回基本的作业信息
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,21 @@
|
||||
package org.jeecg.modules.aiol.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.jeecg.modules.aiol.entity.AiolDiscussion;
|
||||
|
||||
/**
|
||||
* @Description: 讨论保存DTO
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-01-16
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "讨论保存DTO")
|
||||
public class AiolDiscussionSaveDTO extends AiolDiscussion {
|
||||
|
||||
@Schema(description = "关联的章节ID")
|
||||
private String sectionId;
|
||||
}
|
@ -11,10 +11,6 @@ import org.jeecg.modules.aiol.entity.AiolHomework;
|
||||
@Data
|
||||
@Schema(description = "作业保存DTO")
|
||||
public class AiolHomeworkSaveDTO extends AiolHomework {
|
||||
|
||||
@Schema(description = "班级ID,多个用逗号分割")
|
||||
private String classId;
|
||||
|
||||
@Schema(description = "章节ID")
|
||||
private String sectionId;
|
||||
}
|
||||
|
@ -0,0 +1,21 @@
|
||||
package org.jeecg.modules.aiol.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.jeecg.modules.aiol.entity.AiolChat;
|
||||
|
||||
/**
|
||||
* @Description: 会话详情DTO,包含未读消息数
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-01-16
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "会话详情,包含未读消息数")
|
||||
public class ChatWithUnreadCountDTO extends AiolChat {
|
||||
|
||||
@Schema(description = "未读消息数")
|
||||
private Integer unreadCount;
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package org.jeecg.modules.aiol.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.jeecg.modules.aiol.entity.AiolDiscussion;
|
||||
|
||||
/**
|
||||
* @Description: 讨论详情DTO
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-01-16
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "讨论详情")
|
||||
public class DiscussionWithSectionDTO extends AiolDiscussion {
|
||||
|
||||
@Schema(description = "关联的章节ID")
|
||||
private String sectionId;
|
||||
|
||||
@Schema(description = "章节名称")
|
||||
private String sectionName;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package org.jeecg.modules.aiol.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.jeecg.modules.aiol.entity.AiolHomework;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: 作业详情DTO
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2025-01-16
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "作业详情")
|
||||
public class HomeworkWithDetailsDTO extends AiolHomework {
|
||||
|
||||
@Schema(description = "班级名称列表")
|
||||
private List<String> classNames;
|
||||
|
||||
@Schema(description = "章节ID")
|
||||
private String sectionId;
|
||||
|
||||
@Schema(description = "章节标题")
|
||||
private String sectionTitle;
|
||||
}
|
@ -60,7 +60,7 @@ public class AiolChatMember implements Serializable {
|
||||
/**最后已读消息id*/
|
||||
@Excel(name = "最后已读消息id", width = 15)
|
||||
@Schema(description = "最后已读消息id")
|
||||
private java.lang.Integer lastReadMsgId;
|
||||
private java.lang.String lastReadMsgId;
|
||||
/**创建人*/
|
||||
@Schema(description = "创建人")
|
||||
private java.lang.String createBy;
|
||||
|
@ -41,6 +41,10 @@ public class AiolHomework implements Serializable {
|
||||
@Excel(name = "所属课程id", width = 15)
|
||||
@Schema(description = "所属课程id")
|
||||
private java.lang.String courseId;
|
||||
/**班级id*/
|
||||
@Excel(name = "班级id", width = 15)
|
||||
@Schema(description = "班级id")
|
||||
private java.lang.String classId;
|
||||
/**标题*/
|
||||
@Excel(name = "标题", width = 15)
|
||||
@Schema(description = "标题")
|
||||
|
@ -21,6 +21,15 @@ public interface IAiolEntityLinkService extends IService<AiolEntityLink> {
|
||||
*/
|
||||
List<String> listTargetIds(String sourceType, String sourceId, String targetType);
|
||||
|
||||
/**
|
||||
* 根据主体与内容类型查询绑定的 target_id
|
||||
* @param sourceType 主体类型
|
||||
* @param sourceId 主体ID
|
||||
* @param targetType 内容类型
|
||||
* @return target_id
|
||||
*/
|
||||
String listTargetId(String sourceType, String sourceId, String targetType);
|
||||
|
||||
/**
|
||||
* 根据内容与主体类型查询绑定的 source_id 列表
|
||||
* @param targetType 内容类型
|
||||
@ -30,6 +39,16 @@ public interface IAiolEntityLinkService extends IService<AiolEntityLink> {
|
||||
*/
|
||||
List<String> listSourceIds(String targetType, String targetId, String sourceType);
|
||||
|
||||
/**
|
||||
* 根据内容与主体类型查询绑定的 source_id
|
||||
* @param targetType 内容类型
|
||||
* @param targetId 内容ID
|
||||
* @param sourceType 主体类型
|
||||
* @return source_id
|
||||
*/
|
||||
String listSourceId(String targetType, String targetId, String sourceType);
|
||||
|
||||
|
||||
/**
|
||||
* 保存主体与内容类型的绑定关系
|
||||
* @param sourceType 主体类型
|
||||
|
@ -54,4 +54,32 @@ public class AiolEntityLinkServiceImpl extends ServiceImpl<AiolEntityLinkMapper,
|
||||
entityLink.setTargetId(targetId);
|
||||
this.save(entityLink);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String listSourceId(String targetType, String targetId, String sourceType) {
|
||||
LambdaQueryWrapper<AiolEntityLink> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(AiolEntityLink::getTargetType, targetType)
|
||||
.eq(AiolEntityLink::getTargetId, targetId)
|
||||
.eq(AiolEntityLink::getSourceType, sourceType)
|
||||
.select(AiolEntityLink::getSourceId)
|
||||
.last("LIMIT 1");
|
||||
return this.list(qw).stream()
|
||||
.map(AiolEntityLink::getSourceId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String listTargetId(String sourceType, String sourceId, String targetType) {
|
||||
LambdaQueryWrapper<AiolEntityLink> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(AiolEntityLink::getSourceType, sourceType)
|
||||
.eq(AiolEntityLink::getSourceId, sourceId)
|
||||
.eq(AiolEntityLink::getTargetType, targetType)
|
||||
.select(AiolEntityLink::getTargetId)
|
||||
.last("LIMIT 1");
|
||||
return this.list(qw).stream()
|
||||
.map(AiolEntityLink::getTargetId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,11 @@ export const columns: BasicColumn[] = [
|
||||
align:"center",
|
||||
dataIndex: 'courseId'
|
||||
},
|
||||
{
|
||||
title: '班级id',
|
||||
align:"center",
|
||||
dataIndex: 'classId'
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
align:"center",
|
||||
@ -76,6 +81,11 @@ export const formSchema: FormSchema[] = [
|
||||
field: 'courseId',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '班级id',
|
||||
field: 'classId',
|
||||
component: 'Input',
|
||||
},
|
||||
{
|
||||
label: '标题',
|
||||
field: 'title',
|
||||
@ -161,17 +171,18 @@ export const formSchema: FormSchema[] = [
|
||||
// 高级查询数据
|
||||
export const superQuerySchema = {
|
||||
courseId: {title: '所属课程id',order: 0,view: 'text', type: 'string',},
|
||||
title: {title: '标题',order: 1,view: 'text', type: 'string',},
|
||||
description: {title: '说明',order: 2,view: 'umeditor', type: 'string',},
|
||||
attachment: {title: '附件',order: 3,view: 'file', type: 'string',},
|
||||
maxScore: {title: '满分',order: 4,view: 'number', type: 'number',},
|
||||
passScore: {title: '及格分数',order: 5,view: 'number', type: 'number',},
|
||||
startTime: {title: '开始时间',order: 6,view: 'datetime', type: 'string',},
|
||||
endTime: {title: '结束时间',order: 7,view: 'datetime', type: 'string',},
|
||||
status: {title: '状态',order: 8,view: 'number', type: 'number',dictCode: 'course_status',},
|
||||
allowMakeup: {title: '是否允许补交',order: 9,view: 'number', type: 'number',},
|
||||
makeupTime: {title: '补交截止时间',order: 10,view: 'datetime', type: 'string',},
|
||||
notifyTime: {title: '作业通知时间',order: 11,view: 'number', type: 'number',},
|
||||
classId: {title: '班级id',order: 1,view: 'text', type: 'string',},
|
||||
title: {title: '标题',order: 2,view: 'text', type: 'string',},
|
||||
description: {title: '说明',order: 3,view: 'umeditor', type: 'string',},
|
||||
attachment: {title: '附件',order: 4,view: 'file', type: 'string',},
|
||||
maxScore: {title: '满分',order: 5,view: 'number', type: 'number',},
|
||||
passScore: {title: '及格分数',order: 6,view: 'number', type: 'number',},
|
||||
startTime: {title: '开始时间',order: 7,view: 'datetime', type: 'string',},
|
||||
endTime: {title: '结束时间',order: 8,view: 'datetime', type: 'string',},
|
||||
status: {title: '状态',order: 9,view: 'number', type: 'number',dictCode: 'course_status',},
|
||||
allowMakeup: {title: '是否允许补交',order: 10,view: 'number', type: 'number',},
|
||||
makeupTime: {title: '补交截止时间',order: 11,view: 'datetime', type: 'string',},
|
||||
notifyTime: {title: '作业通知时间',order: 12,view: 'number', type: 'number',},
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,539 @@
|
||||
<template>
|
||||
<div class="design-editor-fullscreen">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="editor-header">
|
||||
<div class="header-left">
|
||||
<a-button @click="handleBack" type="text" size="small">
|
||||
<template #icon>
|
||||
<Icon icon="ion:arrow-back" />
|
||||
</template>
|
||||
返回
|
||||
</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<span class="page-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-space>
|
||||
<a-button @click="handlePreview" size="small">
|
||||
<template #icon>
|
||||
<Icon icon="ion:eye-outline" />
|
||||
</template>
|
||||
预览
|
||||
</a-button>
|
||||
<a-button @click="handleSave" type="primary" size="small">
|
||||
<template #icon>
|
||||
<Icon icon="ion:save-outline" />
|
||||
</template>
|
||||
保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全屏主体内容区域 -->
|
||||
<div class="editor-main">
|
||||
<!-- 左侧悬停组件库容器 -->
|
||||
<div
|
||||
class="component-library-container"
|
||||
@mouseenter="showComponentLibrary = true"
|
||||
@mouseleave="handleLibraryMouseLeave"
|
||||
>
|
||||
<!-- 触发器 -->
|
||||
<div class="component-library-trigger">
|
||||
<div class="trigger-tab">
|
||||
<Icon icon="ion:apps-outline" />
|
||||
<span>组件库</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 组件库面板 -->
|
||||
<div
|
||||
class="component-library-panel"
|
||||
:class="{ 'show': showComponentLibrary }"
|
||||
>
|
||||
<div class="library-header">
|
||||
<h3>组件库</h3>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showComponentLibrary = false"
|
||||
>
|
||||
<Icon icon="ion:close" />
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="library-content">
|
||||
<ComponentLibrary
|
||||
@drag-start="handleDragStart"
|
||||
@component-add="handleComponentAdd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设计画布区域 -->
|
||||
<div class="canvas-area">
|
||||
<DesignCanvas
|
||||
:components="pageComponents"
|
||||
:selected-component="selectedComponent"
|
||||
@component-select="handleComponentSelect"
|
||||
@component-drop="handleComponentDrop"
|
||||
@component-move="handleComponentMove"
|
||||
@components-change="handleComponentsChange"
|
||||
style="width: 100%; height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧属性面板 -->
|
||||
<div
|
||||
class="property-panel"
|
||||
:class="{ 'show': selectedComponent }"
|
||||
>
|
||||
<div class="property-header">
|
||||
<h3>属性设置</h3>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="selectedComponent = null"
|
||||
>
|
||||
<Icon icon="ion:close" />
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="property-content">
|
||||
<PropertyEditor
|
||||
:component="selectedComponent"
|
||||
@property-change="handlePropertyChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import ComponentLibrary from './components/ComponentLibrary.vue';
|
||||
import DesignCanvas from './components/DesignCanvas.vue';
|
||||
import PropertyEditor from './components/PropertyEditor.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const pageId = computed(() => route.query.id as string);
|
||||
const pageTitle = ref('新建门户');
|
||||
const selectedComponent = ref<any>(null);
|
||||
const pageComponents = ref<any[]>([]);
|
||||
const showComponentLibrary = ref(false);
|
||||
|
||||
function handleBack() {
|
||||
router.push('/online-design/portal');
|
||||
}
|
||||
|
||||
function handleLibraryMouseLeave() {
|
||||
showComponentLibrary.value = false;
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
if (pageId.value) {
|
||||
const routeData = router.resolve(`/online-design/preview?id=${pageId.value}`);
|
||||
window.open(routeData.href, '_blank');
|
||||
} else {
|
||||
createMessage.warning('请先保存页面');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const pageData = {
|
||||
id: pageId.value || generateId(),
|
||||
name: pageTitle.value,
|
||||
components: pageComponents.value,
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 保存到localStorage作为演示
|
||||
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
|
||||
const existingIndex = savedPages.findIndex(p => p.id === pageData.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
savedPages[existingIndex] = pageData;
|
||||
} else {
|
||||
savedPages.push(pageData);
|
||||
}
|
||||
|
||||
localStorage.setItem('online-design-pages', JSON.stringify(savedPages));
|
||||
|
||||
console.log('保存页面数据:', pageData);
|
||||
createMessage.success('保存成功');
|
||||
|
||||
// 如果是新建页面,保存后跳转到编辑页面
|
||||
if (!pageId.value) {
|
||||
router.replace(`/online-design/editor?id=${pageData.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return 'page_' + Date.now() + '_' + Math.random().toString(36).substring(2, 11);
|
||||
}
|
||||
|
||||
function createComponent(type: string) {
|
||||
const componentId = `${type}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
const baseComponent = {
|
||||
id: componentId,
|
||||
type: type,
|
||||
props: {},
|
||||
data: {},
|
||||
style: {},
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
// 根据组件类型设置默认属性
|
||||
switch (type) {
|
||||
case 'carousel':
|
||||
return {
|
||||
...baseComponent,
|
||||
props: {
|
||||
autoplay: true,
|
||||
dots: true,
|
||||
},
|
||||
data: {
|
||||
images: [
|
||||
{ url: 'https://via.placeholder.com/300x150/1890ff/white?text=轮播图1', alt: '轮播图1' },
|
||||
{ url: 'https://via.placeholder.com/300x150/52c41a/white?text=轮播图2', alt: '轮播图2' },
|
||||
{ url: 'https://via.placeholder.com/300x150/fa8c16/white?text=轮播图3', alt: '轮播图3' },
|
||||
],
|
||||
},
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
...baseComponent,
|
||||
props: {
|
||||
content: '这是一段示例文本内容',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
},
|
||||
};
|
||||
case 'image':
|
||||
return {
|
||||
...baseComponent,
|
||||
props: {
|
||||
src: 'https://via.placeholder.com/300x150/1890ff/white?text=示例图片',
|
||||
alt: '示例图片',
|
||||
width: '100%',
|
||||
},
|
||||
};
|
||||
case 'button':
|
||||
return {
|
||||
...baseComponent,
|
||||
props: {
|
||||
text: '按钮',
|
||||
type: 'primary',
|
||||
size: 'middle',
|
||||
},
|
||||
};
|
||||
default:
|
||||
return baseComponent;
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentName(type: string) {
|
||||
const nameMap = {
|
||||
carousel: '轮播图',
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
button: '按钮',
|
||||
container: '容器',
|
||||
columns: '分栏',
|
||||
};
|
||||
return nameMap[type] || type;
|
||||
}
|
||||
|
||||
function handleDragStart(componentType: string) {
|
||||
console.log('开始拖拽组件:', componentType);
|
||||
}
|
||||
|
||||
function handleComponentAdd(componentType: string) {
|
||||
console.log('添加组件:', componentType);
|
||||
// 创建新组件并添加到页面
|
||||
const newComponent = createComponent(componentType);
|
||||
pageComponents.value.push(newComponent);
|
||||
selectedComponent.value = newComponent;
|
||||
createMessage.success(`${getComponentName(componentType)}组件已添加`);
|
||||
}
|
||||
|
||||
function handleComponentSelect(component: any) {
|
||||
selectedComponent.value = component;
|
||||
}
|
||||
|
||||
function handleComponentDrop(newComponent: any) {
|
||||
console.log('组件放置:', newComponent);
|
||||
pageComponents.value.push(newComponent);
|
||||
selectedComponent.value = newComponent;
|
||||
}
|
||||
|
||||
function handleComponentMove(event: any) {
|
||||
console.log('组件移动:', event);
|
||||
// TODO: 实现组件移动逻辑
|
||||
}
|
||||
|
||||
function handleComponentsChange(components: any[]) {
|
||||
pageComponents.value = components;
|
||||
}
|
||||
|
||||
function handlePropertyChange(updatedComponent: any) {
|
||||
const index = pageComponents.value.findIndex(c => c.id === updatedComponent.id);
|
||||
if (index !== -1) {
|
||||
pageComponents.value[index] = updatedComponent;
|
||||
selectedComponent.value = updatedComponent;
|
||||
}
|
||||
}
|
||||
|
||||
function loadPageData() {
|
||||
if (pageId.value) {
|
||||
// 从localStorage加载页面数据
|
||||
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
|
||||
const pageData = savedPages.find((p: any) => p.id === pageId.value);
|
||||
|
||||
if (pageData) {
|
||||
pageTitle.value = `编辑门户 - ${pageData.name}`;
|
||||
pageComponents.value = pageData.components || [];
|
||||
} else {
|
||||
pageTitle.value = `编辑门户 - ${pageId.value}`;
|
||||
pageComponents.value = [];
|
||||
}
|
||||
} else {
|
||||
pageTitle.value = '新建门户';
|
||||
pageComponents.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPageData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.design-editor-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 确保在布局容器中也能全屏显示 */
|
||||
:deep(.ant-layout-content) {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.main-content) {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
height: 48px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧组件库容器 */
|
||||
.component-library-container {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 48px;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.component-library-container * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 左侧组件库触发器 */
|
||||
.component-library-trigger {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger-tab {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 12px 8px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
writing-mode: vertical-lr;
|
||||
text-orientation: mixed;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trigger-tab:hover {
|
||||
background: #40a9ff;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.trigger-tab span {
|
||||
writing-mode: vertical-lr;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
||||
/* 组件库面板 */
|
||||
.component-library-panel {
|
||||
position: absolute;
|
||||
left: -320px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
background: white;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15);
|
||||
transition: left 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.component-library-panel.show {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.library-header {
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.library-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.library-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 设计画布区域 */
|
||||
.canvas-area {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transition: right 0.3s ease;
|
||||
background: #f0f2f5;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 右侧属性面板 */
|
||||
.property-panel {
|
||||
position: fixed;
|
||||
right: -360px;
|
||||
top: 48px;
|
||||
bottom: 0;
|
||||
width: 360px;
|
||||
background: white;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
|
||||
transition: right 0.3s ease;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.property-panel.show {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.property-panel.show ~ .canvas-area {
|
||||
right: 360px;
|
||||
}
|
||||
|
||||
.property-header {
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.property-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.property-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1200px) {
|
||||
.component-library-panel {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.property-panel {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.property-panel.show ~ .canvas-area {
|
||||
right: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
224
jeecgboot-vue3/src/views/aiol/online-design/portal/Preview.vue
Normal file
224
jeecgboot-vue3/src/views/aiol/online-design/portal/Preview.vue
Normal file
@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="preview-container">
|
||||
<!-- 预览工具栏 -->
|
||||
<div class="preview-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a-button @click="handleBack" type="text">
|
||||
<template #icon>
|
||||
<Icon icon="ion:arrow-back" />
|
||||
</template>
|
||||
返回
|
||||
</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<span class="preview-title">{{ pageTitle }} - 预览</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space>
|
||||
<a-button @click="handleEdit">
|
||||
<template #icon>
|
||||
<Icon icon="ion:create-outline" />
|
||||
</template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button @click="handleRefresh">
|
||||
<template #icon>
|
||||
<Icon icon="ion:refresh-outline" />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容区域 -->
|
||||
<div class="preview-content">
|
||||
<div class="preview-wrapper">
|
||||
<div class="preview-page" :style="pageStyle">
|
||||
<!-- 渲染页面组件 -->
|
||||
<div
|
||||
v-for="(component, index) in pageComponents"
|
||||
:key="component.id || index"
|
||||
class="preview-component"
|
||||
:style="getComponentStyle(component)"
|
||||
>
|
||||
<component
|
||||
:is="getComponentType(component.type)"
|
||||
v-bind="component.props"
|
||||
:data="component.data"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getComponentType as getDesignComponentType } from './components/design-components';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const pageId = computed(() => route.query.id as string);
|
||||
const pageTitle = ref('门户预览');
|
||||
const pageComponents = ref([]);
|
||||
const pageStyle = ref({
|
||||
minHeight: '100%',
|
||||
background: '#ffffff',
|
||||
});
|
||||
|
||||
function handleBack() {
|
||||
router.push('/online-design/portal');
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/online-design/editor?id=${pageId.value}`);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadPageData();
|
||||
createMessage.success('刷新成功');
|
||||
}
|
||||
|
||||
function getComponentType(type: string) {
|
||||
return getDesignComponentType(type);
|
||||
}
|
||||
|
||||
function getComponentStyle(component: any) {
|
||||
return {
|
||||
position: 'relative',
|
||||
width: component.style?.width || '100%',
|
||||
height: component.style?.height || 'auto',
|
||||
margin: component.style?.margin || '0',
|
||||
padding: component.style?.padding || '0',
|
||||
...component.style,
|
||||
};
|
||||
}
|
||||
|
||||
function loadPageData() {
|
||||
// TODO: 从后端加载页面数据
|
||||
if (pageId.value) {
|
||||
// 模拟数据
|
||||
pageTitle.value = `门户页面 ${pageId.value}`;
|
||||
pageComponents.value = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'carousel',
|
||||
props: {
|
||||
autoplay: true,
|
||||
dots: true,
|
||||
},
|
||||
data: {
|
||||
images: [
|
||||
{ url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1', alt: 'Slide 1' },
|
||||
{ url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2', alt: 'Slide 2' },
|
||||
{ url: 'https://via.placeholder.com/800x300/FF9800/white?text=Slide+3', alt: 'Slide 3' },
|
||||
],
|
||||
},
|
||||
style: {
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'text',
|
||||
props: {
|
||||
tag: 'h1',
|
||||
},
|
||||
data: {
|
||||
content: '欢迎来到我们的企业门户',
|
||||
},
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'text',
|
||||
props: {
|
||||
tag: 'p',
|
||||
},
|
||||
data: {
|
||||
content: '这是一个使用在线设计器创建的门户页面示例。您可以通过拖拽组件来快速构建美观的页面。',
|
||||
},
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto 40px',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPageData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
height: 60px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-component {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="component-library">
|
||||
<div class="library-header">
|
||||
<h3>组件库</h3>
|
||||
</div>
|
||||
|
||||
<div class="library-content">
|
||||
<!-- 基础组件分类 -->
|
||||
<div class="component-category">
|
||||
<div class="category-title">
|
||||
<Icon icon="ion:cube-outline" />
|
||||
<span>基础组件</span>
|
||||
</div>
|
||||
|
||||
<div class="component-list">
|
||||
<!-- 轮播图组件 -->
|
||||
<div
|
||||
class="component-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, 'carousel')"
|
||||
@dragend="handleDragEnd"
|
||||
@click="handleAddComponent('carousel')"
|
||||
>
|
||||
<div class="component-icon">
|
||||
<Icon icon="ion:images-outline" />
|
||||
</div>
|
||||
<div class="component-info">
|
||||
<div class="component-name">轮播图</div>
|
||||
<div class="component-desc">图片轮播展示</div>
|
||||
</div>
|
||||
<div class="add-button">
|
||||
<Icon icon="ion:add-outline" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本组件 -->
|
||||
<div
|
||||
class="component-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, 'text')"
|
||||
@dragend="handleDragEnd"
|
||||
@click="handleAddComponent('text')"
|
||||
>
|
||||
<div class="component-icon">
|
||||
<Icon icon="ion:text-outline" />
|
||||
</div>
|
||||
<div class="component-info">
|
||||
<div class="component-name">文本</div>
|
||||
<div class="component-desc">文字内容展示</div>
|
||||
</div>
|
||||
<div class="add-button">
|
||||
<Icon icon="ion:add-outline" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片组件 -->
|
||||
<div
|
||||
class="component-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, 'image')"
|
||||
@dragend="handleDragEnd"
|
||||
@click="handleAddComponent('image')"
|
||||
>
|
||||
<div class="component-icon">
|
||||
<Icon icon="ion:image-outline" />
|
||||
</div>
|
||||
<div class="component-info">
|
||||
<div class="component-name">图片</div>
|
||||
<div class="component-desc">单张图片展示</div>
|
||||
</div>
|
||||
<div class="add-button">
|
||||
<Icon icon="ion:add-outline" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮组件 -->
|
||||
<div
|
||||
class="component-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, 'button')"
|
||||
@dragend="handleDragEnd"
|
||||
@click="handleAddComponent('button')"
|
||||
>
|
||||
<div class="component-icon">
|
||||
<Icon icon="ion:radio-button-on-outline" />
|
||||
</div>
|
||||
<div class="component-info">
|
||||
<div class="component-name">按钮</div>
|
||||
<div class="component-desc">交互按钮</div>
|
||||
</div>
|
||||
<div class="add-button">
|
||||
<Icon icon="ion:add-outline" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 布局组件分类 -->
|
||||
<div class="component-category">
|
||||
<div class="category-title">
|
||||
<Icon icon="ion:grid-outline" />
|
||||
<span>布局组件</span>
|
||||
</div>
|
||||
|
||||
<div class="component-list">
|
||||
<!-- 容器组件 -->
|
||||
<div
|
||||
class="component-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, 'container')"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div class="component-icon">
|
||||
<Icon icon="ion:square-outline" />
|
||||
</div>
|
||||
<div class="component-info">
|
||||
<div class="component-name">容器</div>
|
||||
<div class="component-desc">内容容器</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分栏组件 -->
|
||||
<div
|
||||
class="component-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, 'columns')"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<div class="component-icon">
|
||||
<Icon icon="ion:apps-outline" />
|
||||
</div>
|
||||
<div class="component-info">
|
||||
<div class="component-name">分栏</div>
|
||||
<div class="component-desc">多列布局</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
const emit = defineEmits(['drag-start', 'drag-end', 'component-add']);
|
||||
|
||||
function handleDragStart(event: DragEvent, componentType: string) {
|
||||
if (event.dataTransfer) {
|
||||
// 设置拖拽数据
|
||||
event.dataTransfer.setData('component-type', componentType);
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// 创建拖拽时的预览图像
|
||||
const dragImage = createDragImage(componentType);
|
||||
event.dataTransfer.setDragImage(dragImage, 50, 25);
|
||||
}
|
||||
|
||||
emit('drag-start', componentType);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
emit('drag-end');
|
||||
}
|
||||
|
||||
function handleAddComponent(componentType: string) {
|
||||
emit('component-add', componentType);
|
||||
}
|
||||
|
||||
function createDragImage(componentType: string) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = 100;
|
||||
canvas.height = 50;
|
||||
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#1890ff';
|
||||
ctx.fillRect(0, 0, 100, 50);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(getComponentName(componentType), 50, 30);
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getComponentName(type: string) {
|
||||
const nameMap = {
|
||||
carousel: '轮播图',
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
button: '按钮',
|
||||
container: '容器',
|
||||
columns: '分栏',
|
||||
};
|
||||
return nameMap[type] || type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.component-library {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.library-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.library-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.library-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.component-category {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.category-title .anticon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.component-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.component-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.component-item:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.component-item:hover .add-button {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.component-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.component-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.component-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.component-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.component-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%) scale(0.8);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background: #40a9ff;
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
</style>
|
@ -0,0 +1,831 @@
|
||||
<template>
|
||||
<div class="design-canvas">
|
||||
<!-- 简化的工具栏 -->
|
||||
<div class="canvas-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="canvas-title">设计画布</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<a-space size="small">
|
||||
<!-- 网格控制 -->
|
||||
<a-switch
|
||||
v-model:checked="showGrid"
|
||||
size="small"
|
||||
/>
|
||||
<span class="control-label">网格</span>
|
||||
|
||||
<!-- 缩放控制 -->
|
||||
<a-button size="small" @click="handleZoomOut" :disabled="zoomLevel <= 0.5">
|
||||
<Icon icon="ion:remove-outline" />
|
||||
</a-button>
|
||||
<span class="zoom-display">{{ Math.round(zoomLevel * 100) }}%</span>
|
||||
<a-button size="small" @click="handleZoomIn" :disabled="zoomLevel >= 2">
|
||||
<Icon icon="ion:add-outline" />
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleZoomReset">
|
||||
<Icon icon="ion:refresh-outline" />
|
||||
</a-button>
|
||||
|
||||
<a-divider type="vertical" />
|
||||
|
||||
<a-button size="small" @click="handleClearAll" danger>
|
||||
<Icon icon="ion:trash-outline" />
|
||||
清空
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全屏画布容器 -->
|
||||
<div class="canvas-container">
|
||||
<div
|
||||
class="canvas-content"
|
||||
:class="{ 'drag-over': isDragOver, 'show-grid': showGrid }"
|
||||
:style="canvasStyle"
|
||||
@drop="handleDrop"
|
||||
@dragover="handleDragOver"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
>
|
||||
<!-- 网格背景 -->
|
||||
<div class="grid-background" v-if="showGrid"></div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="components.length === 0" class="empty-canvas">
|
||||
<div class="empty-icon">
|
||||
<Icon icon="ion:cube-outline" />
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
<p>从左侧组件库拖拽组件到网格中开始设计</p>
|
||||
<p class="empty-subtext">支持网格布局和组件缩放</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网格布局组件容器 -->
|
||||
<div v-else class="components-grid">
|
||||
<div
|
||||
v-for="(element, index) in componentList"
|
||||
:key="element.id"
|
||||
class="component-item"
|
||||
:class="{ 'selected': selectedComponent?.id === element.id, 'dragging': element.isDragging }"
|
||||
:style="getComponentStyle(element, index)"
|
||||
@click="handleComponentClick(element)"
|
||||
@mousedown="handleMouseDown(element, $event)"
|
||||
>
|
||||
<!-- 组件工具栏 -->
|
||||
<div class="component-toolbar" v-show="selectedComponent?.id === element.id">
|
||||
<a-space size="small">
|
||||
<a-button size="small" type="text" @click.stop="handleCopy(element)">
|
||||
<Icon icon="ion:copy-outline" />
|
||||
</a-button>
|
||||
<a-button size="small" type="text" danger @click.stop="handleDelete(index)">
|
||||
<Icon icon="ion:trash-outline" />
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 组件内容 -->
|
||||
<div class="component-content">
|
||||
<component
|
||||
:is="getComponentType(element.type)"
|
||||
v-bind="element.props"
|
||||
:data="element.data"
|
||||
:design-mode="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 缩放手柄 -->
|
||||
<div
|
||||
v-show="selectedComponent?.id === element.id"
|
||||
class="resize-handles"
|
||||
>
|
||||
<div class="resize-handle resize-se" @mousedown.stop="handleResizeStart(element, $event, 'se')"></div>
|
||||
<div class="resize-handle resize-sw" @mousedown.stop="handleResizeStart(element, $event, 'sw')"></div>
|
||||
<div class="resize-handle resize-ne" @mousedown.stop="handleResizeStart(element, $event, 'ne')"></div>
|
||||
<div class="resize-handle resize-nw" @mousedown.stop="handleResizeStart(element, $event, 'nw')"></div>
|
||||
<div class="resize-handle resize-n" @mousedown.stop="handleResizeStart(element, $event, 'n')"></div>
|
||||
<div class="resize-handle resize-s" @mousedown.stop="handleResizeStart(element, $event, 's')"></div>
|
||||
<div class="resize-handle resize-e" @mousedown.stop="handleResizeStart(element, $event, 'e')"></div>
|
||||
<div class="resize-handle resize-w" @mousedown.stop="handleResizeStart(element, $event, 'w')"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { getComponentType as getDesignComponentType, createComponentInstance } from './design-components';
|
||||
|
||||
interface ComponentData {
|
||||
id: string;
|
||||
type: string;
|
||||
props: Record<string, any>;
|
||||
data: Record<string, any>;
|
||||
style: Record<string, any>;
|
||||
// 网格布局属性
|
||||
gridX?: number;
|
||||
gridY?: number;
|
||||
gridWidth?: number;
|
||||
gridHeight?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
components: ComponentData[];
|
||||
selectedComponent: ComponentData | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['component-select', 'component-drop', 'component-move', 'components-change']);
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const isDragOver = ref(false);
|
||||
const componentList = computed({
|
||||
get: () => props.components,
|
||||
set: (value) => emit('components-change', value),
|
||||
});
|
||||
|
||||
// 新增:网格和缩放相关状态
|
||||
const showGrid = ref(true);
|
||||
const zoomLevel = ref(1);
|
||||
|
||||
// 组件配置
|
||||
const componentWidth = ref(33.33); // 组件宽度百分比 (1/3)
|
||||
const componentHeight = ref(200); // 组件固定高度
|
||||
const componentsPerRow = ref(3); // 每行组件数量
|
||||
|
||||
// 拖拽相关状态
|
||||
const isDragging = ref(false);
|
||||
const dragData = ref<{
|
||||
component: ComponentData;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startIndex: number;
|
||||
longPressTimer: number | null;
|
||||
} | null>(null);
|
||||
|
||||
// 计算画布样式
|
||||
const canvasStyle = computed(() => ({
|
||||
transform: `scale(${zoomLevel.value})`,
|
||||
transformOrigin: 'top left',
|
||||
width: '100%',
|
||||
minHeight: '100%',
|
||||
}));
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer!.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragOver.value = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
// 只有当离开画布区域时才设置为false
|
||||
if (!(event.currentTarget as Element).contains(event.relatedTarget as Node)) {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
|
||||
const componentType = event.dataTransfer?.getData('component-type');
|
||||
if (componentType) {
|
||||
const newComponent = createComponent(componentType);
|
||||
emit('component-drop', newComponent);
|
||||
}
|
||||
}
|
||||
|
||||
function createComponent(type: string): ComponentData {
|
||||
const component = createComponentInstance(type);
|
||||
// 设置组件的网格位置
|
||||
const nextPosition = getNextPosition();
|
||||
component.gridIndex = nextPosition;
|
||||
return component;
|
||||
}
|
||||
|
||||
// 获取下一个可用位置
|
||||
function getNextPosition(): number {
|
||||
return componentList.value.length;
|
||||
}
|
||||
|
||||
// 计算组件样式
|
||||
function getComponentStyle(component: ComponentData, index: number) {
|
||||
const row = Math.floor(index / componentsPerRow.value);
|
||||
const col = index % componentsPerRow.value;
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${col * componentWidth.value}%`,
|
||||
top: `${row * (componentHeight.value + 20)}px`, // 20px间距
|
||||
width: `${componentWidth.value - 1}%`, // 减去1%作为间距
|
||||
height: `${componentHeight.value}px`,
|
||||
zIndex: component.isDragging ? 1000 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// 查找下一个可用的网格位置
|
||||
function findNextAvailablePosition() {
|
||||
const occupied = new Set<string>();
|
||||
|
||||
// 记录已占用的网格位置
|
||||
componentList.value.forEach(comp => {
|
||||
if (comp.gridX !== undefined && comp.gridY !== undefined &&
|
||||
comp.gridWidth !== undefined && comp.gridHeight !== undefined) {
|
||||
for (let x = comp.gridX; x < comp.gridX + comp.gridWidth; x++) {
|
||||
for (let y = comp.gridY; y < comp.gridY + comp.gridHeight; y++) {
|
||||
occupied.add(`${x},${y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 查找第一个可用位置
|
||||
for (let y = 0; y < gridRows; y++) {
|
||||
for (let x = 0; x < gridCols; x++) {
|
||||
if (!occupied.has(`${x},${y}`)) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { x: 0, y: 0 }; // 如果没有可用位置,返回原点
|
||||
}
|
||||
|
||||
// 获取组件默认宽度(网格单位)
|
||||
function getDefaultWidth(type: string): number {
|
||||
switch (type) {
|
||||
case 'carousel': return 12; // 轮播图占半行
|
||||
case 'image': return 6; // 图片占1/4行
|
||||
case 'text': return 8; // 文本占1/3行
|
||||
case 'button': return 4; // 按钮占1/6行
|
||||
default: return 6;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始拖拽
|
||||
function startDragging(component: ComponentData, startX: number, startY: number, startIndex: number) {
|
||||
isDragging.value = true;
|
||||
component.isDragging = true;
|
||||
createMessage.info('长按拖拽模式已激活');
|
||||
}
|
||||
|
||||
// 更新拖拽位置
|
||||
function updateDragPosition(event: MouseEvent) {
|
||||
if (!dragData.value) return;
|
||||
|
||||
// 计算新的位置索引
|
||||
const rect = document.querySelector('.components-grid')?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const relativeX = event.clientX - rect.left;
|
||||
const relativeY = event.clientY - rect.top;
|
||||
|
||||
const col = Math.floor(relativeX / (rect.width / componentsPerRow.value));
|
||||
const row = Math.floor(relativeY / (componentHeight.value + 20));
|
||||
|
||||
const newIndex = Math.min(
|
||||
Math.max(0, row * componentsPerRow.value + col),
|
||||
componentList.value.length - 1
|
||||
);
|
||||
|
||||
// 如果位置改变,重新排序组件
|
||||
if (newIndex !== dragData.value.startIndex) {
|
||||
reorderComponents(dragData.value.startIndex, newIndex);
|
||||
dragData.value.startIndex = newIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// 重新排序组件
|
||||
function reorderComponents(fromIndex: number, toIndex: number) {
|
||||
const newComponents = [...componentList.value];
|
||||
const [movedComponent] = newComponents.splice(fromIndex, 1);
|
||||
newComponents.splice(toIndex, 0, movedComponent);
|
||||
emit('components-change', newComponents);
|
||||
}
|
||||
|
||||
// 完成拖拽
|
||||
function finishDragging() {
|
||||
if (dragData.value) {
|
||||
dragData.value.component.isDragging = false;
|
||||
}
|
||||
isDragging.value = false;
|
||||
createMessage.success('组件位置已更新');
|
||||
}
|
||||
|
||||
function handleComponentClick(component: ComponentData) {
|
||||
if (!isDragging.value) {
|
||||
emit('component-select', component);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理鼠标按下事件(长按开始拖拽)
|
||||
function handleMouseDown(component: ComponentData, event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startIndex = componentList.value.findIndex(c => c.id === component.id);
|
||||
|
||||
// 设置长按定时器
|
||||
const longPressTimer = setTimeout(() => {
|
||||
startDragging(component, startX, startY, startIndex);
|
||||
}, 500); // 500ms长按
|
||||
|
||||
dragData.value = {
|
||||
component,
|
||||
startX,
|
||||
startY,
|
||||
startIndex,
|
||||
longPressTimer,
|
||||
};
|
||||
|
||||
// 监听鼠标抬起和移动事件
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!dragData.value) return;
|
||||
|
||||
const deltaX = Math.abs(event.clientX - dragData.value.startX);
|
||||
const deltaY = Math.abs(event.clientY - dragData.value.startY);
|
||||
|
||||
// 如果移动距离超过阈值,取消长按
|
||||
if ((deltaX > 10 || deltaY > 10) && dragData.value.longPressTimer) {
|
||||
clearTimeout(dragData.value.longPressTimer);
|
||||
dragData.value.longPressTimer = null;
|
||||
}
|
||||
|
||||
// 如果正在拖拽,更新位置
|
||||
if (isDragging.value && dragData.value) {
|
||||
updateDragPosition(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (dragData.value?.longPressTimer) {
|
||||
clearTimeout(dragData.value.longPressTimer);
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
finishDragging();
|
||||
}
|
||||
|
||||
dragData.value = null;
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
|
||||
// 缩放控制函数
|
||||
function handleZoomIn() {
|
||||
if (zoomLevel.value < 2) {
|
||||
zoomLevel.value = Math.min(2, zoomLevel.value + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (zoomLevel.value > 0.5) {
|
||||
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoomReset() {
|
||||
zoomLevel.value = 1;
|
||||
}
|
||||
|
||||
// 获取网格项样式
|
||||
function getGridItemStyle(component: ComponentData) {
|
||||
const x = component.gridX || 0;
|
||||
const y = component.gridY || 0;
|
||||
const width = component.gridWidth || 6;
|
||||
const height = component.gridHeight || 4;
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${x * gridSize}px`,
|
||||
top: `${y * gridSize}px`,
|
||||
width: `${width * gridSize}px`,
|
||||
height: `${height * gridSize}px`,
|
||||
...component.style,
|
||||
};
|
||||
}
|
||||
|
||||
// 组件缩放处理
|
||||
function handleResizeStart(component: ComponentData, event: MouseEvent, direction?: string) {
|
||||
if (!direction) return; // 只有指定方向才开始缩放
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
isResizing.value = true;
|
||||
resizeData.value = {
|
||||
component,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startWidth: component.gridWidth || 6,
|
||||
startHeight: component.gridHeight || 4,
|
||||
direction,
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleResizeMove);
|
||||
document.addEventListener('mouseup', handleResizeEnd);
|
||||
}
|
||||
|
||||
function handleResizeMove(event: MouseEvent) {
|
||||
if (!isResizing.value || !resizeData.value) return;
|
||||
|
||||
const { component, startX, startY, startWidth, startHeight, direction } = resizeData.value;
|
||||
const deltaX = Math.round((event.clientX - startX) / (gridSize * zoomLevel.value));
|
||||
const deltaY = Math.round((event.clientY - startY) / (gridSize * zoomLevel.value));
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
|
||||
// 根据缩放方向计算新尺寸
|
||||
if (direction.includes('e')) newWidth = Math.max(1, startWidth + deltaX);
|
||||
if (direction.includes('w')) newWidth = Math.max(1, startWidth - deltaX);
|
||||
if (direction.includes('s')) newHeight = Math.max(1, startHeight + deltaY);
|
||||
if (direction.includes('n')) newHeight = Math.max(1, startHeight - deltaY);
|
||||
|
||||
// 更新组件尺寸
|
||||
component.gridWidth = newWidth;
|
||||
component.gridHeight = newHeight;
|
||||
|
||||
emit('component-select', component); // 触发更新
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
isResizing.value = false;
|
||||
resizeData.value = null;
|
||||
document.removeEventListener('mousemove', handleResizeMove);
|
||||
document.removeEventListener('mouseup', handleResizeEnd);
|
||||
}
|
||||
|
||||
function handleMoveUp(index: number) {
|
||||
if (index > 0) {
|
||||
const newComponents = [...componentList.value];
|
||||
[newComponents[index - 1], newComponents[index]] = [newComponents[index], newComponents[index - 1]];
|
||||
emit('components-change', newComponents);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveDown(index: number) {
|
||||
if (index < componentList.value.length - 1) {
|
||||
const newComponents = [...componentList.value];
|
||||
[newComponents[index], newComponents[index + 1]] = [newComponents[index + 1], newComponents[index]];
|
||||
emit('components-change', newComponents);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy(component: ComponentData) {
|
||||
const newComponent = {
|
||||
...component,
|
||||
id: `${component.type}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
|
||||
};
|
||||
|
||||
const newComponents = [...componentList.value, newComponent];
|
||||
emit('components-change', newComponents);
|
||||
createMessage.success('组件复制成功');
|
||||
}
|
||||
|
||||
function handleDelete(index: number) {
|
||||
const newComponents = componentList.value.filter((_, i) => i !== index);
|
||||
emit('components-change', newComponents);
|
||||
emit('component-select', null);
|
||||
createMessage.success('组件删除成功');
|
||||
}
|
||||
|
||||
function handleClearAll() {
|
||||
emit('components-change', []);
|
||||
emit('component-select', null);
|
||||
createMessage.success('画布已清空');
|
||||
}
|
||||
|
||||
function handleSortStart() {
|
||||
// 拖拽开始
|
||||
}
|
||||
|
||||
function handleSortEnd() {
|
||||
// 拖拽结束
|
||||
}
|
||||
|
||||
function getComponentType(type: string) {
|
||||
return getDesignComponentType(type);
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
// 初始化时为没有网格属性的组件设置默认值
|
||||
componentList.value.forEach(component => {
|
||||
if (component.gridX === undefined || component.gridY === undefined) {
|
||||
const nextPosition = findNextAvailablePosition();
|
||||
component.gridX = nextPosition.x;
|
||||
component.gridY = nextPosition.y;
|
||||
component.gridWidth = component.gridWidth || getDefaultWidth(component.type);
|
||||
component.gridHeight = component.gridHeight || getDefaultHeight(component.type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理事件监听器
|
||||
document.removeEventListener('mousemove', handleResizeMove);
|
||||
document.removeEventListener('mouseup', handleResizeEnd);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.design-canvas {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
position: relative;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.canvas-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.canvas-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.canvas-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.grid-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #f0f2f5;
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.canvas-content {
|
||||
width: 100% !important;
|
||||
min-height: calc(100vh - 48px);
|
||||
background: white;
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.canvas-content.drag-over {
|
||||
background-color: #f0f9ff;
|
||||
border: 2px dashed #1890ff;
|
||||
}
|
||||
|
||||
.canvas-content.show-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, #e8e8e8 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e8e8e8 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.grid-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, #e8e8e8 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e8e8e8 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.empty-canvas {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 14px !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
/* 网格布局容器 */
|
||||
.components-grid {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 组件项样式 */
|
||||
.component-item {
|
||||
position: absolute;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
background: #fafafa;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.component-item:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.component-item.selected {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.component-item.dragging {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
z-index: 1000 !important;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.component-toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.component-content {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 缩放手柄样式 */
|
||||
.resize-handles {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: #1890ff;
|
||||
border: 1px solid white;
|
||||
pointer-events: auto;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
/* 角落手柄 */
|
||||
.resize-se {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.resize-sw {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.resize-ne {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.resize-nw {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
/* 边缘手柄 */
|
||||
.resize-n {
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.resize-s {
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.resize-e {
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.resize-w {
|
||||
left: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
cursor: w-resize;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,854 @@
|
||||
<template>
|
||||
<div class="property-editor">
|
||||
<div class="editor-header">
|
||||
<h3>属性编辑</h3>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<!-- 未选中组件时的提示 -->
|
||||
<div v-if="!component" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<Icon icon="ion:settings-outline" />
|
||||
</div>
|
||||
<p>请选择一个组件来编辑属性</p>
|
||||
</div>
|
||||
|
||||
<!-- 组件属性编辑表单 -->
|
||||
<div v-else class="property-form">
|
||||
<!-- 组件基本信息 -->
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:information-circle-outline" />
|
||||
<span>基本信息</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>组件类型</label>
|
||||
<a-input :value="getComponentTypeName(component.type)" disabled />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>组件ID</label>
|
||||
<a-input :value="component.id" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 轮播图属性 -->
|
||||
<template v-if="component.type === 'carousel'">
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:images-outline" />
|
||||
<span>轮播设置</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>自动播放</label>
|
||||
<a-switch
|
||||
:checked="component.props.autoplay"
|
||||
@change="updateProperty('props.autoplay', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>显示指示点</label>
|
||||
<a-switch
|
||||
:checked="component.props.dots"
|
||||
@change="updateProperty('props.dots', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>显示箭头</label>
|
||||
<a-switch
|
||||
:checked="component.props.arrows"
|
||||
@change="updateProperty('props.arrows', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:image-outline" />
|
||||
<span>图片管理</span>
|
||||
</div>
|
||||
<div class="image-list">
|
||||
<div
|
||||
v-for="(image, index) in component.data.images"
|
||||
:key="index"
|
||||
class="carousel-image-item"
|
||||
>
|
||||
<!-- 图片预览区域 -->
|
||||
<div class="carousel-image-preview">
|
||||
<div v-if="image.url" class="preview-container">
|
||||
<img :src="image.url" :alt="image.alt" />
|
||||
<div class="preview-overlay">
|
||||
<a-button size="small" @click="removeCarouselImage(index)" danger>
|
||||
<template #icon>
|
||||
<Icon icon="ion:trash-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div v-else class="carousel-upload-area" @click="triggerCarouselFileInput(index)">
|
||||
<Icon icon="ion:cloud-upload-outline" />
|
||||
<p>点击上传图片</p>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input
|
||||
:ref="el => setCarouselFileInputRef(index, el)"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleCarouselFileUpload(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片信息编辑 -->
|
||||
<div class="carousel-image-info">
|
||||
<div class="form-row">
|
||||
<a-input
|
||||
v-model:value="image.url"
|
||||
placeholder="或输入图片URL"
|
||||
size="small"
|
||||
@change="updateImageData"
|
||||
/>
|
||||
<a-button size="small" @click="triggerCarouselFileInput(index)">
|
||||
<template #icon>
|
||||
<Icon icon="ion:folder-open-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-input
|
||||
v-model:value="image.title"
|
||||
placeholder="图片标题(可选)"
|
||||
size="small"
|
||||
@change="updateImageData"
|
||||
/>
|
||||
|
||||
<a-input
|
||||
v-model:value="image.description"
|
||||
placeholder="图片描述(可选)"
|
||||
size="small"
|
||||
@change="updateImageData"
|
||||
/>
|
||||
|
||||
<a-input
|
||||
v-model:value="image.alt"
|
||||
placeholder="替代文本(可选)"
|
||||
size="small"
|
||||
@change="updateImageData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-button @click="addImage" type="dashed" block>
|
||||
<template #icon>
|
||||
<Icon icon="ion:add" />
|
||||
</template>
|
||||
添加图片
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文本属性 -->
|
||||
<template v-if="component.type === 'text'">
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:text-outline" />
|
||||
<span>文本内容</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>标签类型</label>
|
||||
<a-select
|
||||
:value="component.props.tag"
|
||||
@change="updateProperty('props.tag', $event)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option value="p">段落 (p)</a-select-option>
|
||||
<a-select-option value="h1">标题1 (h1)</a-select-option>
|
||||
<a-select-option value="h2">标题2 (h2)</a-select-option>
|
||||
<a-select-option value="h3">标题3 (h3)</a-select-option>
|
||||
<a-select-option value="h4">标题4 (h4)</a-select-option>
|
||||
<a-select-option value="span">行内文本 (span)</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>文本内容</label>
|
||||
<a-textarea
|
||||
:value="component.data.content"
|
||||
@change="updateProperty('data.content', $event.target.value)"
|
||||
:rows="4"
|
||||
placeholder="请输入文本内容"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片属性 -->
|
||||
<template v-if="component.type === 'image'">
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:image-outline" />
|
||||
<span>图片设置</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>图片</label>
|
||||
<div class="image-upload-container">
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="component.data.src" class="image-preview-box">
|
||||
<img :src="component.data.src" alt="预览图片" />
|
||||
<div class="image-overlay">
|
||||
<a-button size="small" @click="removeCurrentImage" danger>
|
||||
<template #icon>
|
||||
<Icon icon="ion:trash-outline" />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div v-else class="upload-area" @click="triggerFileInput">
|
||||
<Icon icon="ion:cloud-upload-outline" />
|
||||
<p>点击上传图片</p>
|
||||
<p class="upload-tip">支持 JPG、PNG、GIF 格式</p>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL输入框 -->
|
||||
<div class="url-input-container">
|
||||
<a-input
|
||||
:value="component.data.src"
|
||||
@change="updateProperty('data.src', $event.target.value)"
|
||||
placeholder="或输入图片URL"
|
||||
size="small"
|
||||
/>
|
||||
<a-button size="small" @click="triggerFileInput">
|
||||
<template #icon>
|
||||
<Icon icon="ion:folder-open-outline" />
|
||||
</template>
|
||||
选择文件
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>替代文本</label>
|
||||
<a-input
|
||||
:value="component.props.alt"
|
||||
@change="updateProperty('props.alt', $event.target.value)"
|
||||
placeholder="图片描述"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>显示方式</label>
|
||||
<a-select
|
||||
:value="component.props.objectFit"
|
||||
@change="updateProperty('props.objectFit', $event)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option value="cover">覆盖</a-select-option>
|
||||
<a-select-option value="contain">包含</a-select-option>
|
||||
<a-select-option value="fill">填充</a-select-option>
|
||||
<a-select-option value="none">原始</a-select-option>
|
||||
<a-select-option value="scale-down">缩小</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 按钮属性 -->
|
||||
<template v-if="component.type === 'button'">
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:radio-button-on-outline" />
|
||||
<span>按钮设置</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>按钮文字</label>
|
||||
<a-input
|
||||
:value="component.data.text"
|
||||
@change="updateProperty('data.text', $event.target.value)"
|
||||
placeholder="按钮文字"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>按钮类型</label>
|
||||
<a-select
|
||||
:value="component.props.type"
|
||||
@change="updateProperty('props.type', $event)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option value="primary">主要按钮</a-select-option>
|
||||
<a-select-option value="default">默认按钮</a-select-option>
|
||||
<a-select-option value="dashed">虚线按钮</a-select-option>
|
||||
<a-select-option value="text">文本按钮</a-select-option>
|
||||
<a-select-option value="link">链接按钮</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>按钮大小</label>
|
||||
<a-select
|
||||
:value="component.props.size"
|
||||
@change="updateProperty('props.size', $event)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option value="large">大</a-select-option>
|
||||
<a-select-option value="middle">中</a-select-option>
|
||||
<a-select-option value="small">小</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 样式设置 -->
|
||||
<div class="property-section">
|
||||
<div class="section-title">
|
||||
<Icon icon="ion:color-palette-outline" />
|
||||
<span>样式设置</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>宽度</label>
|
||||
<a-input
|
||||
:value="component.style.width"
|
||||
@change="updateProperty('style.width', $event.target.value)"
|
||||
placeholder="如: 100%, 300px"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>高度</label>
|
||||
<a-input
|
||||
:value="component.style.height"
|
||||
@change="updateProperty('style.height', $event.target.value)"
|
||||
placeholder="如: auto, 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>外边距</label>
|
||||
<a-input
|
||||
:value="component.style.margin"
|
||||
@change="updateProperty('style.margin', $event.target.value)"
|
||||
placeholder="如: 10px, 10px 20px"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>内边距</label>
|
||||
<a-input
|
||||
:value="component.style.padding"
|
||||
@change="updateProperty('style.padding', $event.target.value)"
|
||||
placeholder="如: 10px, 10px 20px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文本样式 -->
|
||||
<template v-if="component.type === 'text'">
|
||||
<div class="form-item">
|
||||
<label>字体大小</label>
|
||||
<a-input
|
||||
:value="component.style.fontSize"
|
||||
@change="updateProperty('style.fontSize', $event.target.value)"
|
||||
placeholder="如: 16px, 1.2em"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>字体颜色</label>
|
||||
<a-input
|
||||
:value="component.style.color"
|
||||
@change="updateProperty('style.color', $event.target.value)"
|
||||
placeholder="如: #333, red"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>文本对齐</label>
|
||||
<a-select
|
||||
:value="component.style.textAlign"
|
||||
@change="updateProperty('style.textAlign', $event)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option value="left">左对齐</a-select-option>
|
||||
<a-select-option value="center">居中</a-select-option>
|
||||
<a-select-option value="right">右对齐</a-select-option>
|
||||
<a-select-option value="justify">两端对齐</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
|
||||
interface ComponentData {
|
||||
id: string;
|
||||
type: string;
|
||||
props: Record<string, any>;
|
||||
data: Record<string, any>;
|
||||
style: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
component: ComponentData | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['property-change']);
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const carouselFileInputs = ref<Record<number, HTMLInputElement>>({});
|
||||
|
||||
function getComponentTypeName(type: string) {
|
||||
const typeNames = {
|
||||
carousel: '轮播图',
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
button: '按钮',
|
||||
container: '容器',
|
||||
columns: '分栏',
|
||||
};
|
||||
return typeNames[type] || type;
|
||||
}
|
||||
|
||||
function updateProperty(path: string, value: any) {
|
||||
if (!props.component) return;
|
||||
|
||||
const keys = path.split('.');
|
||||
const component = { ...props.component };
|
||||
|
||||
let target = component;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!target[keys[i]]) {
|
||||
target[keys[i]] = {};
|
||||
}
|
||||
target = target[keys[i]];
|
||||
}
|
||||
|
||||
target[keys[keys.length - 1]] = value;
|
||||
emit('property-change', component);
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
if (!props.component || props.component.type !== 'carousel') return;
|
||||
|
||||
const newImage = {
|
||||
url: 'https://via.placeholder.com/800x300/666/white?text=New+Image',
|
||||
alt: 'New Image',
|
||||
};
|
||||
|
||||
const component = { ...props.component };
|
||||
component.data.images = [...component.data.images, newImage];
|
||||
emit('property-change', component);
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
if (!props.component || props.component.type !== 'carousel') return;
|
||||
|
||||
const component = { ...props.component };
|
||||
component.data.images = component.data.images.filter((_, i) => i !== index);
|
||||
emit('property-change', component);
|
||||
}
|
||||
|
||||
function updateImageData() {
|
||||
if (!props.component) return;
|
||||
emit('property-change', { ...props.component });
|
||||
}
|
||||
|
||||
// 图片上传相关函数
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function handleFileUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
createMessage.error('请选择图片文件!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件大小(限制为5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
createMessage.error('图片大小不能超过5MB!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用FileReader读取文件并转换为base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const base64 = e.target?.result as string;
|
||||
updateProperty('data.src', base64);
|
||||
createMessage.success('图片上传成功!');
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
createMessage.error('图片读取失败!');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// 清空input值,允许重复选择同一文件
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function removeCurrentImage() {
|
||||
updateProperty('data.src', '');
|
||||
createMessage.success('图片已移除!');
|
||||
}
|
||||
|
||||
// 轮播图文件上传相关函数
|
||||
function setCarouselFileInputRef(index: number, el: HTMLInputElement | null) {
|
||||
if (el) {
|
||||
carouselFileInputs.value[index] = el;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerCarouselFileInput(index: number) {
|
||||
carouselFileInputs.value[index]?.click();
|
||||
}
|
||||
|
||||
function handleCarouselFileUpload(index: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
createMessage.error('请选择图片文件!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件大小(限制为5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
createMessage.error('图片大小不能超过5MB!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用FileReader读取文件并转换为base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const base64 = e.target?.result as string;
|
||||
|
||||
// 更新轮播图中指定索引的图片URL
|
||||
if (props.component && props.component.data.images[index]) {
|
||||
props.component.data.images[index].url = base64;
|
||||
updateImageData();
|
||||
createMessage.success('图片上传成功!');
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
createMessage.error('图片读取失败!');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// 清空input值,允许重复选择同一文件
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function removeCarouselImage(index: number) {
|
||||
if (props.component && props.component.data.images) {
|
||||
props.component.data.images.splice(index, 1);
|
||||
updateImageData();
|
||||
createMessage.success('图片已删除!');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.property-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.property-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title .anticon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-item label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-actions .ant-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-upload-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.image-preview-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-preview-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-preview-box:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.upload-area .anticon {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-area p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px !important;
|
||||
color: #999 !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.url-input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.url-input-container .ant-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 轮播图图片管理样式 */
|
||||
.carousel-image-item {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.carousel-image-preview {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.preview-container:hover .preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carousel-upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.carousel-upload-area:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.carousel-upload-area .anticon {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.carousel-upload-area p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.carousel-image-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-row .ant-input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="button-component" :class="{ 'design-mode': designMode }">
|
||||
<a-button
|
||||
v-bind="buttonProps"
|
||||
:type="type"
|
||||
:size="size"
|
||||
:shape="shape"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:ghost="ghost"
|
||||
:danger="danger"
|
||||
:block="block"
|
||||
class="button-content"
|
||||
:style="buttonStyle"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template #icon v-if="iconName">
|
||||
<Icon :icon="iconName" />
|
||||
</template>
|
||||
{{ text }}
|
||||
</a-button>
|
||||
|
||||
<!-- 设计模式下的空状态 -->
|
||||
<div v-if="designMode && !text" class="empty-button">
|
||||
<div class="empty-content">
|
||||
<Icon icon="ion:radio-button-on-outline" />
|
||||
<p>按钮组件</p>
|
||||
<p class="empty-tip">请在右侧属性面板设置按钮文字</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
interface ButtonData {
|
||||
text: string;
|
||||
url?: string;
|
||||
target?: '_blank' | '_self' | '_parent' | '_top';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// 按钮属性
|
||||
type?: 'primary' | 'default' | 'dashed' | 'text' | 'link';
|
||||
size?: 'large' | 'middle' | 'small';
|
||||
shape?: 'default' | 'circle' | 'round';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
ghost?: boolean;
|
||||
danger?: boolean;
|
||||
block?: boolean;
|
||||
|
||||
// 图标
|
||||
iconName?: string;
|
||||
|
||||
// 数据
|
||||
data?: ButtonData;
|
||||
|
||||
// 是否为设计模式
|
||||
designMode?: boolean;
|
||||
|
||||
// 点击事件
|
||||
onClick?: () => void;
|
||||
|
||||
// 其他属性
|
||||
[key: string]: any;
|
||||
}>(), {
|
||||
type: 'primary',
|
||||
size: 'middle',
|
||||
shape: 'default',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
ghost: false,
|
||||
danger: false,
|
||||
block: false,
|
||||
designMode: false,
|
||||
data: () => ({ text: '' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const text = computed(() => props.data?.text || '');
|
||||
|
||||
const buttonProps = computed(() => {
|
||||
const {
|
||||
type, size, shape, loading, disabled, ghost, danger, block,
|
||||
iconName, data, designMode, onClick, ...rest
|
||||
} = props;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const style: Record<string, any> = {};
|
||||
|
||||
// 从props中提取样式相关的属性
|
||||
const styleProps = [
|
||||
'width', 'height', 'margin', 'padding',
|
||||
'backgroundColor', 'borderColor', 'color',
|
||||
'fontSize', 'fontWeight', 'borderRadius',
|
||||
'boxShadow', 'textTransform'
|
||||
];
|
||||
|
||||
styleProps.forEach(prop => {
|
||||
if (props[prop] !== undefined) {
|
||||
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
style[cssProp] = props[prop];
|
||||
}
|
||||
});
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleClick(event: Event) {
|
||||
if (props.designMode) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理链接跳转
|
||||
if (props.data?.url) {
|
||||
const target = props.data.target || '_self';
|
||||
if (target === '_blank') {
|
||||
window.open(props.data.url, '_blank');
|
||||
} else {
|
||||
window.location.href = props.data.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.onClick) {
|
||||
props.onClick();
|
||||
}
|
||||
|
||||
emit('click', event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-component {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button-component.design-mode {
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.button-component.design-mode:hover {
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.button-content {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button-content:not(.ant-btn-loading):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-button {
|
||||
min-width: 120px;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-content .anticon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
margin-top: 4px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* 按钮尺寸自定义 */
|
||||
:deep(.ant-btn-lg) {
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-sm) {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 块级按钮 */
|
||||
.button-component :deep(.ant-btn-block) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 圆形按钮 */
|
||||
.button-component :deep(.ant-btn-circle) {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.button-content:not(.ant-btn-loading):hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-lg) {
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="carousel-component" :class="{ 'design-mode': designMode }">
|
||||
<a-carousel
|
||||
v-bind="carouselProps"
|
||||
:autoplay="autoplay"
|
||||
:dots="dots"
|
||||
:arrows="arrows"
|
||||
:effect="effect"
|
||||
:dotPosition="dotPosition"
|
||||
>
|
||||
<div v-for="(image, index) in images" :key="index" class="carousel-slide">
|
||||
<div class="slide-content" :style="slideStyle">
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.alt || `Slide ${index + 1}`"
|
||||
class="slide-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-if="image.title || image.description" class="slide-overlay">
|
||||
<h3 v-if="image.title" class="slide-title">{{ image.title }}</h3>
|
||||
<p v-if="image.description" class="slide-description">{{ image.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-carousel>
|
||||
|
||||
<!-- 设计模式下的编辑提示 -->
|
||||
<div v-if="designMode && images.length === 0" class="empty-carousel">
|
||||
<div class="empty-content">
|
||||
<Icon icon="ion:images-outline" />
|
||||
<p>轮播图组件</p>
|
||||
<p class="empty-tip">请在右侧属性面板添加图片</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
interface ImageData {
|
||||
url: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CarouselData {
|
||||
images: ImageData[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// 轮播图属性
|
||||
autoplay?: boolean;
|
||||
dots?: boolean;
|
||||
arrows?: boolean;
|
||||
effect?: 'scrollx' | 'fade';
|
||||
dotPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
// 数据
|
||||
data?: CarouselData;
|
||||
|
||||
// 是否为设计模式
|
||||
designMode?: boolean;
|
||||
|
||||
// 其他属性
|
||||
[key: string]: any;
|
||||
}>(), {
|
||||
autoplay: true,
|
||||
dots: true,
|
||||
arrows: false,
|
||||
effect: 'scrollx',
|
||||
dotPosition: 'bottom',
|
||||
designMode: false,
|
||||
data: () => ({ images: [] }),
|
||||
});
|
||||
|
||||
const images = computed(() => props.data?.images || []);
|
||||
|
||||
const carouselProps = computed(() => {
|
||||
const { autoplay, dots, arrows, effect, dotPosition, data, designMode, ...rest } = props;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const slideStyle = computed(() => ({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}));
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = 'https://via.placeholder.com/800x300/f0f0f0/999?text=Image+Load+Error';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.carousel-component {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-component.design-mode {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slide-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
color: white;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.slide-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.slide-description {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.empty-carousel {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-content .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 12px !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
/* 自定义轮播图样式 */
|
||||
:deep(.ant-carousel) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-slide) {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-slide > div) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-dots) {
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-dots li button) {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-dots li.slick-active button) {
|
||||
background: white;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-arrow) {
|
||||
z-index: 2;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-arrow:hover) {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-prev) {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
:deep(.ant-carousel .slick-next) {
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="image-component" :class="{ 'design-mode': designMode }">
|
||||
<div class="image-wrapper" :style="wrapperStyle">
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
class="image-content"
|
||||
:style="imageStyle"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@click="handleClick"
|
||||
/>
|
||||
|
||||
<!-- 设计模式下的空状态 -->
|
||||
<div v-if="designMode && !src" class="empty-image">
|
||||
<div class="empty-content">
|
||||
<Icon icon="ion:image-outline" />
|
||||
<p>图片组件</p>
|
||||
<p class="empty-tip">请在右侧属性面板设置图片URL</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
interface ImageData {
|
||||
src: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// 图片属性
|
||||
alt?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
|
||||
// 数据
|
||||
data?: ImageData;
|
||||
|
||||
// 样式属性
|
||||
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
|
||||
borderRadius?: string;
|
||||
|
||||
// 是否为设计模式
|
||||
designMode?: boolean;
|
||||
|
||||
// 点击事件
|
||||
onClick?: () => void;
|
||||
|
||||
// 其他属性
|
||||
[key: string]: any;
|
||||
}>(), {
|
||||
alt: '',
|
||||
loading: 'lazy',
|
||||
objectFit: 'cover',
|
||||
designMode: false,
|
||||
data: () => ({ src: '' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'load', 'error']);
|
||||
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
const src = computed(() => props.data?.src || '');
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
const style: Record<string, any> = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
if (props.borderRadius) {
|
||||
style.borderRadius = props.borderRadius;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
const style: Record<string, any> = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
objectFit: props.objectFit,
|
||||
};
|
||||
|
||||
// 从props中提取其他样式属性
|
||||
const styleProps = [
|
||||
'border', 'boxShadow', 'filter', 'opacity', 'transform'
|
||||
];
|
||||
|
||||
styleProps.forEach(prop => {
|
||||
if (props[prop] !== undefined) {
|
||||
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
style[cssProp] = props[prop];
|
||||
}
|
||||
});
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
emit('load', event);
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
imageError.value = true;
|
||||
imageLoaded.value = false;
|
||||
|
||||
// 设置默认错误图片
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = 'https://via.placeholder.com/400x200/f0f0f0/999?text=Image+Load+Error';
|
||||
|
||||
emit('error', event);
|
||||
}
|
||||
|
||||
function handleClick(event: Event) {
|
||||
if (props.onClick) {
|
||||
props.onClick();
|
||||
}
|
||||
emit('click', event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-component {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-component.design-mode {
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.image-component.design-mode:hover {
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.image-content {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.image-content:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.empty-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-content .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 12px !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
/* 图片加载状态 */
|
||||
.image-content[src=""] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 响应式图片 */
|
||||
@media (max-width: 768px) {
|
||||
.image-content:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="text-component" :class="{ 'design-mode': designMode }">
|
||||
<component
|
||||
:is="tag"
|
||||
class="text-content"
|
||||
:style="textStyle"
|
||||
v-html="processedContent"
|
||||
@click="handleClick"
|
||||
/>
|
||||
|
||||
<!-- 设计模式下的编辑提示 -->
|
||||
<div v-if="designMode && !content" class="empty-text">
|
||||
<div class="empty-content">
|
||||
<Icon icon="ion:text-outline" />
|
||||
<p>文本组件</p>
|
||||
<p class="empty-tip">请在右侧属性面板编辑文本内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
interface TextData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// HTML标签类型
|
||||
tag?: 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'div';
|
||||
|
||||
// 数据
|
||||
data?: TextData;
|
||||
|
||||
// 是否为设计模式
|
||||
designMode?: boolean;
|
||||
|
||||
// 点击事件
|
||||
onClick?: () => void;
|
||||
|
||||
// 其他属性
|
||||
[key: string]: any;
|
||||
}>(), {
|
||||
tag: 'p',
|
||||
designMode: false,
|
||||
data: () => ({ content: '' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const content = computed(() => props.data?.content || '');
|
||||
|
||||
const processedContent = computed(() => {
|
||||
if (!content.value) return '';
|
||||
|
||||
// 处理换行符
|
||||
return content.value.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
const textStyle = computed(() => {
|
||||
const style: Record<string, any> = {};
|
||||
|
||||
// 从props中提取样式相关的属性
|
||||
const styleProps = [
|
||||
'fontSize', 'fontWeight', 'fontFamily', 'fontStyle',
|
||||
'color', 'backgroundColor', 'textAlign', 'textDecoration',
|
||||
'lineHeight', 'letterSpacing', 'wordSpacing',
|
||||
'textShadow', 'textTransform',
|
||||
'margin', 'padding', 'width', 'height',
|
||||
'border', 'borderRadius', 'boxShadow'
|
||||
];
|
||||
|
||||
styleProps.forEach(prop => {
|
||||
if (props[prop] !== undefined) {
|
||||
// 将驼峰命名转换为CSS属性名
|
||||
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
style[cssProp] = props[prop];
|
||||
}
|
||||
});
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
if (props.onClick) {
|
||||
props.onClick();
|
||||
}
|
||||
emit('click');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-component {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-component.design-mode {
|
||||
min-height: 40px;
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.text-component.design-mode:hover {
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.text-content:deep(br) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-content .anticon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
margin-top: 4px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* 预设文本样式 */
|
||||
.text-content.heading-1 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.text-content.heading-2 {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.text-content.heading-3 {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.text-content.heading-4 {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.text-content.paragraph {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.text-content.small-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.text-content.large-text {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.text-content.heading-1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.text-content.heading-2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.text-content.heading-3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.text-content.heading-4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.text-content.paragraph,
|
||||
.text-content.large-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,143 @@
|
||||
import { App } from 'vue';
|
||||
import CarouselComponent from './CarouselComponent.vue';
|
||||
import TextComponent from './TextComponent.vue';
|
||||
import ImageComponent from './ImageComponent.vue';
|
||||
import ButtonComponent from './ButtonComponent.vue';
|
||||
|
||||
// 组件映射表
|
||||
export const componentMap = {
|
||||
carousel: CarouselComponent,
|
||||
text: TextComponent,
|
||||
image: ImageComponent,
|
||||
button: ButtonComponent,
|
||||
};
|
||||
|
||||
// 获取组件类型
|
||||
export function getComponentType(type: string) {
|
||||
return componentMap[type] || 'div';
|
||||
}
|
||||
|
||||
// 注册所有设计组件
|
||||
export function registerDesignComponents(app: App) {
|
||||
Object.entries(componentMap).forEach(([name, component]) => {
|
||||
app.component(`Design${name.charAt(0).toUpperCase() + name.slice(1)}Component`, component);
|
||||
});
|
||||
}
|
||||
|
||||
// 导出所有组件
|
||||
export {
|
||||
CarouselComponent,
|
||||
TextComponent,
|
||||
ImageComponent,
|
||||
ButtonComponent,
|
||||
};
|
||||
|
||||
// 组件配置信息
|
||||
export const componentConfigs = {
|
||||
carousel: {
|
||||
name: '轮播图',
|
||||
icon: 'ion:images-outline',
|
||||
description: '图片轮播展示',
|
||||
category: 'basic',
|
||||
defaultProps: {
|
||||
autoplay: true,
|
||||
dots: true,
|
||||
arrows: false,
|
||||
effect: 'scrollx',
|
||||
dotPosition: 'bottom',
|
||||
},
|
||||
defaultData: {
|
||||
images: [
|
||||
{
|
||||
url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1',
|
||||
alt: 'Slide 1'
|
||||
},
|
||||
{
|
||||
url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2',
|
||||
alt: 'Slide 2'
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultStyle: {
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
name: '文本',
|
||||
icon: 'ion:text-outline',
|
||||
description: '文字内容展示',
|
||||
category: 'basic',
|
||||
defaultProps: {
|
||||
tag: 'p',
|
||||
},
|
||||
defaultData: {
|
||||
content: '这是一段文本内容,点击右侧属性面板可以编辑。',
|
||||
},
|
||||
defaultStyle: {
|
||||
fontSize: '16px',
|
||||
color: '#333',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
image: {
|
||||
name: '图片',
|
||||
icon: 'ion:image-outline',
|
||||
description: '单张图片展示',
|
||||
category: 'basic',
|
||||
defaultProps: {
|
||||
alt: '图片',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
defaultData: {
|
||||
src: 'https://via.placeholder.com/400x200/FF9800/white?text=Image',
|
||||
},
|
||||
defaultStyle: {
|
||||
width: '400px',
|
||||
height: '200px',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
button: {
|
||||
name: '按钮',
|
||||
icon: 'ion:radio-button-on-outline',
|
||||
description: '交互按钮',
|
||||
category: 'basic',
|
||||
defaultProps: {
|
||||
type: 'primary',
|
||||
size: 'middle',
|
||||
shape: 'default',
|
||||
},
|
||||
defaultData: {
|
||||
text: '按钮文字',
|
||||
},
|
||||
defaultStyle: {
|
||||
marginBottom: '16px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 根据类型获取组件配置
|
||||
export function getComponentConfig(type: string) {
|
||||
return componentConfigs[type];
|
||||
}
|
||||
|
||||
// 创建新组件实例
|
||||
export function createComponentInstance(type: string) {
|
||||
const config = getComponentConfig(type);
|
||||
if (!config) {
|
||||
throw new Error(`Unknown component type: ${type}`);
|
||||
}
|
||||
|
||||
const id = `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
props: { ...config.defaultProps },
|
||||
data: { ...config.defaultData },
|
||||
style: { ...config.defaultStyle },
|
||||
};
|
||||
}
|
519
jeecgboot-vue3/src/views/aiol/online-design/portal/index.vue
Normal file
519
jeecgboot-vue3/src/views/aiol/online-design/portal/index.vue
Normal file
@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div class="portal-design-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">门户设计</h1>
|
||||
<p class="page-subtitle">创建和管理您的门户页面设计</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索门户设计..."
|
||||
style="width: 300px; margin-right: 16px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<a-button type="primary" size="large" @click="handleCreate">
|
||||
<template #icon>
|
||||
<Icon icon="ion:add" />
|
||||
</template>
|
||||
新建门户
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 门户卡片网格 -->
|
||||
<div class="portal-grid-container">
|
||||
<a-spin :spinning="loading">
|
||||
<!-- 新建门户卡片 -->
|
||||
<div class="portal-grid">
|
||||
<div class="portal-card create-card" @click="handleCreate">
|
||||
<div class="create-card-content">
|
||||
<div class="create-icon">
|
||||
<Icon icon="ion:add-outline" />
|
||||
</div>
|
||||
<div class="create-text">新建门户</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 现有门户卡片 -->
|
||||
<div
|
||||
v-for="portal in filteredPortals"
|
||||
:key="portal.id"
|
||||
class="portal-card"
|
||||
@click="handleEdit(portal)"
|
||||
>
|
||||
<!-- 缩略图 -->
|
||||
<div class="portal-thumbnail">
|
||||
<div class="thumbnail-placeholder">
|
||||
<Icon icon="ion:desktop-outline" />
|
||||
<span>{{ portal.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬停遮罩 -->
|
||||
<div class="portal-overlay">
|
||||
<div class="overlay-actions">
|
||||
<a-button type="primary" @click.stop="handleEdit(portal)">
|
||||
<template #icon>
|
||||
<Icon icon="ion:create-outline" />
|
||||
</template>
|
||||
设计
|
||||
</a-button>
|
||||
<a-button @click.stop="handlePreview(portal)">
|
||||
<template #icon>
|
||||
<Icon icon="ion:eye-outline" />
|
||||
</template>
|
||||
预览
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片信息 -->
|
||||
<div class="portal-info">
|
||||
<div class="portal-title">{{ portal.name }}</div>
|
||||
<div class="portal-meta">
|
||||
<span class="portal-status" :class="{ 'published': portal.status === 1 }">
|
||||
{{ portal.status === 1 ? '已发布' : '草稿' }}
|
||||
</span>
|
||||
<span class="portal-time">{{ formatTime(portal.updateTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作菜单 -->
|
||||
<div class="portal-actions">
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small" @click.stop>
|
||||
<Icon icon="ion:ellipsis-horizontal" />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleEdit(portal)">
|
||||
<Icon icon="ion:create-outline" />
|
||||
编辑设计
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handlePreview(portal)">
|
||||
<Icon icon="ion:eye-outline" />
|
||||
预览
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleCopy(portal)">
|
||||
<Icon icon="ion:copy-outline" />
|
||||
复制
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleDelete(portal)" class="danger-item">
|
||||
<Icon icon="ion:trash-outline" />
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredPortals.length === 0 && !loading" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<Icon icon="ion:desktop-outline" />
|
||||
</div>
|
||||
<div class="empty-text">
|
||||
<h3>还没有门户设计</h3>
|
||||
<p>点击"新建门户"开始创建您的第一个门户页面</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<Icon icon="ion:add" />
|
||||
</template>
|
||||
新建门户
|
||||
</a-button>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
|
||||
const router = useRouter();
|
||||
const { createMessage } = useMessage();
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<any[]>([]);
|
||||
const searchKeyword = ref('');
|
||||
|
||||
// 过滤后的门户列表
|
||||
const filteredPortals = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return dataSource.value;
|
||||
}
|
||||
return dataSource.value.filter(portal =>
|
||||
portal.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
|
||||
portal.description.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
function handleSearch(value: string) {
|
||||
searchKeyword.value = value;
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
function formatTime(time: string) {
|
||||
if (!time) return '';
|
||||
const date = new Date(time);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return '今天';
|
||||
} else if (days === 1) {
|
||||
return '昨天';
|
||||
} else if (days < 7) {
|
||||
return `${days}天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
// 复制门户
|
||||
function handleCopy(portal: any) {
|
||||
const newPortal = {
|
||||
...portal,
|
||||
id: Date.now(),
|
||||
name: `${portal.name} - 副本`,
|
||||
status: 0, // 设为草稿
|
||||
createTime: new Date().toISOString(),
|
||||
updateTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
dataSource.value.push(newPortal);
|
||||
saveToLocalStorage();
|
||||
createMessage.success('门户复制成功');
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
router.push('/online-design/editor');
|
||||
}
|
||||
|
||||
function handleEdit(record: any) {
|
||||
router.push(`/online-design/editor?id=${record.id}`);
|
||||
}
|
||||
|
||||
function handlePreview(record: any) {
|
||||
router.push(`/online-design/preview?id=${record.id}`);
|
||||
}
|
||||
|
||||
function handleDelete(record: any) {
|
||||
// 从localStorage删除页面数据
|
||||
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
|
||||
const filteredPages = savedPages.filter((p: any) => p.id !== record.id);
|
||||
localStorage.setItem('online-design-pages', JSON.stringify(filteredPages));
|
||||
|
||||
createMessage.success('删除成功');
|
||||
// 重新加载数据
|
||||
loadData();
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
function saveToLocalStorage() {
|
||||
localStorage.setItem('portal-designs', JSON.stringify(dataSource.value));
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
loading.value = true;
|
||||
|
||||
// 从localStorage加载数据(演示用)
|
||||
setTimeout(() => {
|
||||
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
|
||||
|
||||
// 添加一些示例数据
|
||||
if (savedPages.length === 0) {
|
||||
const exampleData = [
|
||||
{
|
||||
id: '1',
|
||||
name: '企业官网首页',
|
||||
description: '公司官方网站首页设计',
|
||||
status: 1,
|
||||
createTime: '2024-08-06 10:00:00',
|
||||
updateTime: '2024-08-06 15:30:00',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '产品展示页',
|
||||
description: '产品介绍和展示页面',
|
||||
status: 0,
|
||||
createTime: '2024-08-06 14:20:00',
|
||||
updateTime: '2024-08-06 16:45:00',
|
||||
},
|
||||
];
|
||||
localStorage.setItem('online-design-pages', JSON.stringify(exampleData));
|
||||
dataSource.value = exampleData;
|
||||
} else {
|
||||
dataSource.value = savedPages;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portal-design-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-grid-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.portal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 新建卡片样式 */
|
||||
.create-card {
|
||||
border: 2px dashed #d9d9d9;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.create-card:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
.create-card-content {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.create-card:hover .create-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.create-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-card:hover .create-text {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 门户缩略图 */
|
||||
.portal-thumbnail {
|
||||
position: relative;
|
||||
height: 140px;
|
||||
background: #f8f9fa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbnail-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #8c8c8c;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.thumbnail-placeholder span {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 悬停遮罩 */
|
||||
.portal-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.portal-card:hover .portal-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overlay-actions .ant-btn {
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 卡片信息 */
|
||||
.portal-info {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.portal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.portal-status {
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #f5f5f5;
|
||||
color: #8c8c8c;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.portal-status.published {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.portal-time {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 操作菜单 */
|
||||
.portal-actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.portal-card:hover .portal-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text h3 {
|
||||
font-size: 18px;
|
||||
color: #262626;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text p {
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 危险操作样式 */
|
||||
:deep(.danger-item) {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
|
||||
:deep(.danger-item:hover) {
|
||||
background: #fff2f0 !important;
|
||||
}
|
||||
</style>
|
135
jeecgboot-vue3/src/views/aiol/online-design/test.vue
Normal file
135
jeecgboot-vue3/src/views/aiol/online-design/test.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="test-page">
|
||||
<h1>在线设计模块测试页面</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>组件测试</h2>
|
||||
|
||||
<!-- 轮播图组件测试 -->
|
||||
<div class="component-test">
|
||||
<h3>轮播图组件</h3>
|
||||
<CarouselComponent
|
||||
:autoplay="true"
|
||||
:dots="true"
|
||||
:arrows="true"
|
||||
:data="{
|
||||
images: [
|
||||
{ url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1', alt: 'Slide 1' },
|
||||
{ url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2', alt: 'Slide 2' },
|
||||
{ url: 'https://via.placeholder.com/800x300/FF9800/white?text=Slide+3', alt: 'Slide 3' }
|
||||
]
|
||||
}"
|
||||
style="width: 100%; height: 300px; margin-bottom: 20px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文本组件测试 -->
|
||||
<div class="component-test">
|
||||
<h3>文本组件</h3>
|
||||
<TextComponent
|
||||
tag="h2"
|
||||
:data="{ content: '这是一个标题文本组件' }"
|
||||
style="color: #333; font-size: 24px; margin-bottom: 16px;"
|
||||
/>
|
||||
<TextComponent
|
||||
tag="p"
|
||||
:data="{ content: '这是一个段落文本组件,支持多行文本显示。可以通过属性面板编辑文本内容和样式。' }"
|
||||
style="color: #666; font-size: 16px; line-height: 1.6; margin-bottom: 16px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片组件测试 -->
|
||||
<div class="component-test">
|
||||
<h3>图片组件</h3>
|
||||
<ImageComponent
|
||||
alt="测试图片"
|
||||
:data="{ src: 'https://via.placeholder.com/400x200/FF9800/white?text=Image+Component' }"
|
||||
style="width: 400px; height: 200px; margin-bottom: 16px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 按钮组件测试 -->
|
||||
<div class="component-test">
|
||||
<h3>按钮组件</h3>
|
||||
<ButtonComponent
|
||||
type="primary"
|
||||
size="large"
|
||||
:data="{ text: '主要按钮' }"
|
||||
style="margin-right: 16px; margin-bottom: 16px;"
|
||||
/>
|
||||
<ButtonComponent
|
||||
type="default"
|
||||
size="middle"
|
||||
:data="{ text: '默认按钮' }"
|
||||
style="margin-right: 16px; margin-bottom: 16px;"
|
||||
/>
|
||||
<ButtonComponent
|
||||
type="dashed"
|
||||
size="small"
|
||||
:data="{ text: '虚线按钮' }"
|
||||
style="margin-bottom: 16px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>功能测试</h2>
|
||||
<p>请访问 <a href="/online-design/portal" target="_blank">/online-design/portal</a> 测试完整的在线设计功能。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CarouselComponent from './portal/components/design-components/CarouselComponent.vue';
|
||||
import TextComponent from './portal/components/design-components/TextComponent.vue';
|
||||
import ImageComponent from './portal/components/design-components/ImageComponent.vue';
|
||||
import ButtonComponent from './portal/components/design-components/ButtonComponent.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-page {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.component-test {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.component-test h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #666;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user